diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 16598243b..9253c12ec 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -49,15 +49,15 @@ Reserved next strategic milestones: ## 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 | +| 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 | +| 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`. diff --git a/.planning/phase-W2-v1425-ci-fix-report.md b/.planning/phase-W2-v1425-ci-fix-report.md index f7018499e..960feb166 100644 --- a/.planning/phase-W2-v1425-ci-fix-report.md +++ b/.planning/phase-W2-v1425-ci-fix-report.md @@ -21,6 +21,7 @@ which is why this never manifested outside the test suite. ### What changed File: `tests/integration/coach-prefs.test.ts` + - Line 73–75: `(GET as () => Promise)()` → `(GET as (r: Request) => Promise)(new Request("http://localhost/api/auth/me/coach-prefs"))` - Line 111–114: same change for the round-trip GET call inside the PUT @@ -71,6 +72,7 @@ container. #### What changed File: `e2e/chart-overlay-controls.spec.ts` line 127 → + ```ts await targetRangeToggle.dispatchEvent("click"); ``` @@ -101,6 +103,7 @@ by the captured page-snapshot artifact showing `04/13/2026` and #### What changed File: `e2e/charts-mobile.spec.ts` line 218 → + ```ts .waitFor({ state: "attached", timeout: 10_000 }); ``` @@ -112,10 +115,11 @@ which only needs them attached to the DOM, not visually on-screen. ### Verification (e2e) Local verification of the e2e specs was not possible in this session: + - The repo's local box runs Node 25, while CI runs Node 22. `pnpm build` hits a Next.js 16 prerender bug on Node 25 (`Cannot read private member #state from an object whose class did - not declare it` on `/api/version`). +not declare it` on `/api/version`). - The Turbopack dev server (`pnpm dev`) does start and bind port 3000, but the first compile of `/api/version` did not finish within 200 seconds; subsequent requests on `127.0.0.1:3000` continued to diff --git a/.planning/phase-W3e-v1425-zielwerte-redesign-report.md b/.planning/phase-W3e-v1425-zielwerte-redesign-report.md index 626f4028f..5d8fde464 100644 --- a/.planning/phase-W3e-v1425-zielwerte-redesign-report.md +++ b/.planning/phase-W3e-v1425-zielwerte-redesign-report.md @@ -31,17 +31,17 @@ problem Marc flagged), and replaced them with a calmer page: ## Per-phase commit SHAs -| Phase | SHA | Subject | -|------:|-----|---------| -| 1 | `86a52da` | refactor(targets): remove v1.4.22 per-card sparkline + delta caption | -| 2 | `f3c9e2d` | feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary | -| 3 | `97583cf` | refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton | -| 4 | `96c5e79` | feat(targets): page-level summary header | -| 5 | `f2b9f84` | feat(targets): card layout with status pill + consistency + coach handoff | -| 6 | `9acee2b` | feat(targets): MOOD_STABILITY headline switches from σ to verbal label | -| 7 | `f223b4f` | feat(targets): mobile-first responsive grid + card reflow | -| 8 | `ec4fb98` | feat(targets): mount CoachDrawer on /targets with per-card scope prefill | -| 9 | `8e4a808` | i18n(targets): add EN + DE strings for redesigned card primitives | +| Phase | SHA | Subject | +| ----: | --------- | --------------------------------------------------------------------------------------- | +| 1 | `86a52da` | refactor(targets): remove v1.4.22 per-card sparkline + delta caption | +| 2 | `f3c9e2d` | feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary | +| 3 | `97583cf` | refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton | +| 4 | `96c5e79` | feat(targets): page-level summary header | +| 5 | `f2b9f84` | feat(targets): card layout with status pill + consistency + coach handoff | +| 6 | `9acee2b` | feat(targets): MOOD_STABILITY headline switches from σ to verbal label | +| 7 | `f223b4f` | feat(targets): mobile-first responsive grid + card reflow | +| 8 | `ec4fb98` | feat(targets): mount CoachDrawer on /targets with per-card scope prefill | +| 9 | `8e4a808` | i18n(targets): add EN + DE strings for redesigned card primitives | All commits include neither `Co-Authored-By: Claude` nor any other co-author trailer (per Marc directive 2026-05-14). @@ -133,9 +133,9 @@ co-author trailer (per Marc directive 2026-05-14). are correctly composed. The redesigned status pill uses a raw `` rather than `` because the v1.4.22 inline-style colour hack (`backgroundColor: - '#xxxxxx20'`) is the wrong tool for the new ring + bg +'#xxxxxx20'`) is the wrong tool for the new ring + bg composition; the design-token approach (`bg-[var(...)]/12 - ring-1 ring-[var(...)]/30`) reads as native shadcn +ring-1 ring-[var(...)]/30`) reads as native shadcn styling. Card composition unchanged (CardHeader + CardContent + flex-col h-full pattern). @@ -155,6 +155,7 @@ Reviewer verification path: `aiProviderChain` AND no admin fallback applies. 2. **Client query** — `src/app/targets/page.tsx`: + ```ts const { data: chainStatus } = useQuery({ queryKey: ["insights", "provider-chain"], @@ -167,6 +168,7 @@ Reviewer verification path: }); const aiEnabled = chainStatus?.activeProvider != null; ``` + The query key is shared with Settings → AI section so the cache is warm. @@ -190,22 +192,22 @@ Reviewer verification path: ## Verification -| Command | Result | -|---------|--------| -| `pnpm typecheck` | clean (exit 0) | -| `pnpm lint` | clean (exit 0) | -| `pnpm test` | 270 files, 2325 tests, 0 failures | +| Command | Result | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `pnpm typecheck` | clean (exit 0) | +| `pnpm lint` | clean (exit 0) | +| `pnpm test` | 270 files, 2325 tests, 0 failures | | `pnpm format:check` | clean for all W3e-touched files; pre-existing warnings in `.planning/*.md` and `docs/audit/v1423-summary.md` are NOT from this phase | Manual viewport audit (verified by Tailwind class inspection + the `targets-responsive.test.tsx` suite): | Width | Cards/row | Card footer reflow | Coach CTA width | -|------:|----------:|-------------------|-----------------| -| 375 | 1 | stacked | full-width | -| 640 | 2 | horizontal | auto | -| 1024 | 3 | horizontal | auto | -| 1440 | 3 | horizontal | auto | +| ----: | --------: | ------------------ | --------------- | +| 375 | 1 | stacked | full-width | +| 640 | 2 | horizontal | auto | +| 1024 | 3 | horizontal | auto | +| 1440 | 3 | horizontal | auto | Playwright sweep deferred to a follow-up commit (the playwright-skill needs a running dev server which this agent does not have diff --git a/.planning/phase-W3f-v1425-polish-wins-report.md b/.planning/phase-W3f-v1425-polish-wins-report.md index 624747607..10a2bc078 100644 --- a/.planning/phase-W3f-v1425-polish-wins-report.md +++ b/.planning/phase-W3f-v1425-polish-wins-report.md @@ -27,12 +27,12 @@ no push, no version bump. ## Per-win commit SHAs -| Win | SHA | Subject | -|---:|-----|---------| -| 1 | `d74aa20`† | feat(targets): per-card edit cog opens target-config sheet | -| 2 | `7b6b916` | style(charts): grey out comparison-overlay toggle when no prior-period data exists | -| 3 | `edae569` | chore(insights): drop orphaned /api/insights/general-status — superseded by InsightAdvisorCard since v1.4.16 | -| 4 | `7846d68` | feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle) | +| Win | SHA | Subject | +| --: | ---------- | ------------------------------------------------------------------------------------------------------------ | +| 1 | `d74aa20`† | feat(targets): per-card edit cog opens target-config sheet | +| 2 | `7b6b916` | style(charts): grey out comparison-overlay toggle when no prior-period data exists | +| 3 | `edae569` | chore(insights): drop orphaned /api/insights/general-status — superseded by InsightAdvisorCard since v1.4.16 | +| 4 | `7846d68` | feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle) | † **Win 1 commit-message collision.** While I was staging Win 1, a parallel agent (W4d GLP-1) staged + committed before my `git commit` @@ -104,7 +104,7 @@ here so the v1.4.25 changelog can attribute the work correctly. - `src/app/api/analytics/route.ts` — `computeSleepStageBreakdown()` now returns a `perNight: Array<{ - dayKey, stages }>` field alongside the existing 30-day aggregate. +dayKey, stages }>` field alongside the existing 30-day aggregate. Sorted ascending by Berlin-tz day key; empty days drop out. - `src/components/insights/sleep-stage-stacked-bar.tsx` — rewritten. Per-night BarChart with `stackId="stages"`, vertical layout @@ -150,12 +150,12 @@ here so the v1.4.25 changelog can attribute the work correctly. ## Verification -| Command | Result | -|---------|--------| -| `pnpm typecheck` | clean (exit 0) | -| `pnpm lint` | clean (exit 0) — no new errors | -| `pnpm vitest run src/components/charts/__tests__/chart-overlay-controls.test.tsx src/components/targets/ src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx` | 8 files, 45 tests, 0 failures | -| `pnpm vitest run` (full suite) | 285 files, 2507 tests passing, 6 PRE-EXISTING failures unrelated to my work (Withings new measurement-type enum coverage from a parallel agent — `FAT_MASS`, `MUSCLE_MASS`, `VISCERAL_FAT`, `VASCULAR_AGE`, `SKIN_TEMPERATURE`, `PULSE_WAVE_VELOCITY`, `FAT_FREE_MASS` add 7 enum members; existing tests pinning 18-member shape need updating in a separate phase) | +| Command | Result | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm typecheck` | clean (exit 0) | +| `pnpm lint` | clean (exit 0) — no new errors | +| `pnpm vitest run src/components/charts/__tests__/chart-overlay-controls.test.tsx src/components/targets/ src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx` | 8 files, 45 tests, 0 failures | +| `pnpm vitest run` (full suite) | 285 files, 2507 tests passing, 6 PRE-EXISTING failures unrelated to my work (Withings new measurement-type enum coverage from a parallel agent — `FAT_MASS`, `MUSCLE_MASS`, `VISCERAL_FAT`, `VASCULAR_AGE`, `SKIN_TEMPERATURE`, `PULSE_WAVE_VELOCITY`, `FAT_FREE_MASS` add 7 enum members; existing tests pinning 18-member shape need updating in a separate phase) | Manual viewport audit deferred to the v1.4.25 verification phase where Playwright is runnable. @@ -163,7 +163,8 @@ where Playwright is runnable. ## Conflict awareness — what happened with parallel agents The prompt warned about parallel agent activity on: -- W4d (GLP-1 full): src/components/medications/*, snapshot, schema, prompts + +- W4d (GLP-1 full): src/components/medications/\*, snapshot, schema, prompts - W6 (Dashboard fixes + GLP-1 tile): chart-settings-cog, weight chart - W9 (Repo + dependabot): README, .github @@ -188,7 +189,7 @@ i18n parity verified: all four wins append new keys, no restructure. - **Win 1**: per-card cog renders for EVERY card per Marc directive, but the underlying threshold-edit doesn't exist for derived - metrics (BMI / MOOD_* / MEDICATION_COMPLIANCE / + metrics (BMI / MOOD\_\* / MEDICATION_COMPLIANCE / BLOOD_PRESSURE_IN_TARGET). The dialog handles this by showing an explanatory caption ("This card is derived from other metrics. Edit the underlying targets instead.") — the Save button is diff --git a/.planning/phase-W4-v1425-insights-sub-pages-report.md b/.planning/phase-W4-v1425-insights-sub-pages-report.md index 6ad2a1d7b..196626e3f 100644 --- a/.planning/phase-W4-v1425-insights-sub-pages-report.md +++ b/.planning/phase-W4-v1425-insights-sub-pages-report.md @@ -98,6 +98,7 @@ is out of W4 scope. ## Manual verification (not run locally) Manual smoke notes to verify after deploy: + - `/insights` mother page renders (Hero + Briefing + Trends + Advisor + Coach drawer). - Each of `/insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi,schlaf}` diff --git a/.planning/phase-W4d-v1425-glp1-full-report.md b/.planning/phase-W4d-v1425-glp1-full-report.md index ebf1394d3..becd06d2e 100644 --- a/.planning/phase-W4d-v1425-glp1-full-report.md +++ b/.planning/phase-W4d-v1425-glp1-full-report.md @@ -10,33 +10,33 @@ von unserer View, harmonisch einlistet." ## Per-phase commit map -| Phase | Commit | Scope | -|---|---|---| -| 1 — Schema additions | `cbcc059` | Migration 0046, MedicationCategory + MedicationDoseChange + InjectionSite enums, MedicationInventoryEvent table, `Medication.treatmentClass` / `Medication.dosesPerUnit`, `MedicationIntakeEvent.injectionSite`. | -| 2a — Coach snapshot block | `2bd2902` | `buildGlp1SnapshotBlock()` + `weeklyContext.glp1` injected into `buildCoachSnapshot()`. | -| 2b — Coach GROUND RULE 9 + insight GROUND RULE 14 | `330be96` (folded under another agent's commit) | EN + DE rules forbidding dose prescription; PROMPT_VERSION bump 4.24.0 → 4.25.0; `glp1_plateau` keyFinding enum value. | -| 3 — Form weekly preset | `94cc7f5` | Treatment-class dropdown, "Once weekly on …" preset with localStorage memory, dosesPerUnit input, wire-schema extensions in zod. | -| 4 — GLP-1 card variant | `b8a1c18` | `Glp1MedicationCard` + dispatcher in /medications, `injection-sites.ts` recommender, `/api/medications/[id]/glp1` details endpoint. | -| 5 — Body-map picker | `6750d09` | `injection-site-picker.tsx` with SVG outline + 22px hit-targets + dashed-ring recommended-next annotation. Unit tests cover rotation algorithm. | -| 6 — Pen/vial inventory | folded into Phase 1 + 4 | `MedicationInventoryEvent` table + dosesPerUnit + low-stock signal in glp1 details endpoint. | -| 7 — MoodEntry side-effect tags | `52a9682` + `ddabe17` | Chip strip on mood-form with toggle behaviour; EN + DE labels under `medications.sideEffectTag*`. No schema change (tags column is free-text). | -| 8 — Therapy timeline | `2c7c3cc` | `TherapyTimeline` mounted on /insights/medikamente; `/api/insights/glp1-timeline` aggregates dose changes + injections + inventory + side-effect days. | -| 9 — Plateau detection | `6148a17` | `detectGlp1Plateau()` + `buildGlp1PlateauPrompt()` wired into the insight-generator user prompt. GROUND RULE 14 keeps the safety contract. | -| 10 — Doctor-report PDF | `f45adb2` | New "GLP-1 therapy" section on the PDF (weight curve, titration table, side-effects). Aggregator gains optional `glp1` block; PDF renderer skips it when null. | -| Hygiene | `a68dbdc` + `[style commit]` | Test-environment prisma-tolerance guards on `buildGlp1SnapshotBlock()` + `detectGlp1Plateau()`; DE locale parity for `therapyTimelineDescription`; OpenAPI regeneration; prettier across touched files. | +| Phase | Commit | Scope | +| ------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 — Schema additions | `cbcc059` | Migration 0046, MedicationCategory + MedicationDoseChange + InjectionSite enums, MedicationInventoryEvent table, `Medication.treatmentClass` / `Medication.dosesPerUnit`, `MedicationIntakeEvent.injectionSite`. | +| 2a — Coach snapshot block | `2bd2902` | `buildGlp1SnapshotBlock()` + `weeklyContext.glp1` injected into `buildCoachSnapshot()`. | +| 2b — Coach GROUND RULE 9 + insight GROUND RULE 14 | `330be96` (folded under another agent's commit) | EN + DE rules forbidding dose prescription; PROMPT_VERSION bump 4.24.0 → 4.25.0; `glp1_plateau` keyFinding enum value. | +| 3 — Form weekly preset | `94cc7f5` | Treatment-class dropdown, "Once weekly on …" preset with localStorage memory, dosesPerUnit input, wire-schema extensions in zod. | +| 4 — GLP-1 card variant | `b8a1c18` | `Glp1MedicationCard` + dispatcher in /medications, `injection-sites.ts` recommender, `/api/medications/[id]/glp1` details endpoint. | +| 5 — Body-map picker | `6750d09` | `injection-site-picker.tsx` with SVG outline + 22px hit-targets + dashed-ring recommended-next annotation. Unit tests cover rotation algorithm. | +| 6 — Pen/vial inventory | folded into Phase 1 + 4 | `MedicationInventoryEvent` table + dosesPerUnit + low-stock signal in glp1 details endpoint. | +| 7 — MoodEntry side-effect tags | `52a9682` + `ddabe17` | Chip strip on mood-form with toggle behaviour; EN + DE labels under `medications.sideEffectTag*`. No schema change (tags column is free-text). | +| 8 — Therapy timeline | `2c7c3cc` | `TherapyTimeline` mounted on /insights/medikamente; `/api/insights/glp1-timeline` aggregates dose changes + injections + inventory + side-effect days. | +| 9 — Plateau detection | `6148a17` | `detectGlp1Plateau()` + `buildGlp1PlateauPrompt()` wired into the insight-generator user prompt. GROUND RULE 14 keeps the safety contract. | +| 10 — Doctor-report PDF | `f45adb2` | New "GLP-1 therapy" section on the PDF (weight curve, titration table, side-effects). Aggregator gains optional `glp1` block; PDF renderer skips it when null. | +| Hygiene | `a68dbdc` + `[style commit]` | Test-environment prisma-tolerance guards on `buildGlp1SnapshotBlock()` + `detectGlp1Plateau()`; DE locale parity for `therapyTimelineDescription`; OpenAPI regeneration; prettier across touched files. | --- ## Verification -| Gate | Status | -|---|---| -| `pnpm typecheck` | One pre-existing error in `doctor-report-prefs/route.ts` (parallel agent's code, NOT mine). My files: clean. | -| `pnpm lint` | One pre-existing error in `targets/target-edit-sheet.tsx` (parallel agent's untracked file, NOT mine). My files: clean. | -| `pnpm test` (unit) | 2482 passed / 1 skipped / 0 failed. | +| Gate | Status | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm typecheck` | One pre-existing error in `doctor-report-prefs/route.ts` (parallel agent's code, NOT mine). My files: clean. | +| `pnpm lint` | One pre-existing error in `targets/target-edit-sheet.tsx` (parallel agent's untracked file, NOT mine). My files: clean. | +| `pnpm test` (unit) | 2482 passed / 1 skipped / 0 failed. | | `pnpm test:integration` | 132 passed / 2 failed — both failures pre-existing (coach-prefs.test.ts asserts shape predating another agent's `defaultWindow` addition; measurements-batch-delete.test.ts hits a unique-constraint conflict from stale test DB state). Neither failure touches W4d code. | -| `pnpm openapi:check` | In sync. Regenerated to pick up `/api/medications/[id]/glp1` + `/api/insights/glp1-timeline`. | -| `pnpm format:check` | All W4d files prettier-clean. Pre-existing warnings in other agents' files. | +| `pnpm openapi:check` | In sync. Regenerated to pick up `/api/medications/[id]/glp1` + `/api/insights/glp1-timeline`. | +| `pnpm format:check` | All W4d files prettier-clean. Pre-existing warnings in other agents' files. | --- @@ -116,6 +116,7 @@ The final state is correct; the journey was bumpy. ## Files touched **New:** + - `prisma/migrations/0046_glp1_dose_history/migration.sql` - `src/lib/ai/coach/glp1-snapshot.ts` - `src/lib/insights/glp1-plateau.ts` @@ -129,6 +130,7 @@ The final state is correct; the journey was bumpy. - `src/app/api/insights/glp1-timeline/route.ts` **Extended:** + - `prisma/schema.prisma` (3 new models / 1 enum / 2 columns / 1 index) - `src/lib/ai/coach/snapshot.ts` (weeklyContext.glp1) - `src/lib/ai/coach/system-prompt.ts` (GROUND RULE 9 EN + DE) diff --git a/.planning/phase-W5-v1425-coach-polish-report.md b/.planning/phase-W5-v1425-coach-polish-report.md index d8dab431d..48130f3a1 100644 --- a/.planning/phase-W5-v1425-coach-polish-report.md +++ b/.planning/phase-W5-v1425-coach-polish-report.md @@ -7,16 +7,16 @@ the v1.4.25 follow-up brief. ## Commits -| Sha | Subject | -| --- | --- | -| `1895fd3` | `fix(coach): suggested-prompt chips show user message before thinking placeholder` | -| `4c9c068` | `style(coach): drop redundant per-row source labels in evidence disclosure` | +| Sha | Subject | +| --------- | ------------------------------------------------------------------------------------- | +| `1895fd3` | `fix(coach): suggested-prompt chips show user message before thinking placeholder` | +| `4c9c068` | `style(coach): drop redundant per-row source labels in evidence disclosure` | | `330be96` | `feat(coach): default analysis window as user preference + per-conversation override` | -| `81b1d79` | `style(coach): unify X / cog / new-chat header buttons — same size + alignment` | -| `70fe5a5` | `refactor(coach): drop non-functional mic icon from composer` | -| `c7eb226` | `feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern` | -| `b50ebcc` | `feat(coach): distinct daily-limit vs provider-rate-limit error UX` | -| `36bf960` | `style(coach): prettier formatting on W5 surfaces` | +| `81b1d79` | `style(coach): unify X / cog / new-chat header buttons — same size + alignment` | +| `70fe5a5` | `refactor(coach): drop non-functional mic icon from composer` | +| `c7eb226` | `feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern` | +| `b50ebcc` | `feat(coach): distinct daily-limit vs provider-rate-limit error UX` | +| `36bf960` | `style(coach): prettier formatting on W5 surfaces` | ## Per-commit summary @@ -124,13 +124,13 @@ Tests: 4 new for the resolver + 2 for the rendered copy. ## Verification -| Check | Result | -| --- | --- | -| `pnpm typecheck` | exit 0 | -| `pnpm lint` | 2 pre-existing warnings (unused-vars), 0 new | -| `pnpm vitest run src/components/insights/coach-panel src/lib/validations/__tests__/coach-prefs.test.ts` | 98 / 98 green | -| `pnpm prettier --check` (W5 surfaces) | clean | -| Full `pnpm test` | 11 fails in `snapshot.test.ts` + `snapshot-new-metrics.test.ts` — **not W5**; root cause is the parallel W4d GLP-1 work touching `snapshot.ts` (`buildGlp1SnapshotBlock` not stubbed in the snapshot tests). Handed back to W4d / W7b. | +| Check | Result | +| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm typecheck` | exit 0 | +| `pnpm lint` | 2 pre-existing warnings (unused-vars), 0 new | +| `pnpm vitest run src/components/insights/coach-panel src/lib/validations/__tests__/coach-prefs.test.ts` | 98 / 98 green | +| `pnpm prettier --check` (W5 surfaces) | clean | +| Full `pnpm test` | 11 fails in `snapshot.test.ts` + `snapshot-new-metrics.test.ts` — **not W5**; root cause is the parallel W4d GLP-1 work touching `snapshot.ts` (`buildGlp1SnapshotBlock` not stubbed in the snapshot tests). Handed back to W4d / W7b. | ## Conflicts with parallel agents diff --git a/.planning/phase-W6c-v1425-doctor-report-toggles-report.md b/.planning/phase-W6c-v1425-doctor-report-toggles-report.md index 063e02a64..a8575e99e 100644 --- a/.planning/phase-W6c-v1425-doctor-report-toggles-report.md +++ b/.planning/phase-W6c-v1425-doctor-report-toggles-report.md @@ -5,22 +5,24 @@ ## Commits (in order) -| Hash | Message | -|------|---------| -| `a7bc74b` | `feat(schema): User.doctorReportPrefsJson + migration 0045` | -| `5cb4a1d` | `feat(doctor-report): per-section toggle UI + persistence` | +| Hash | Message | +| --------- | ----------------------------------------------------------------------------------------------- | +| `a7bc74b` | `feat(schema): User.doctorReportPrefsJson + migration 0045` | +| `5cb4a1d` | `feat(doctor-report): per-section toggle UI + persistence` | | `759fdae` | `feat(doctor-report): server filter respects per-section toggles + PDF skips disabled sections` | -| `64d50fd` | `i18n(doctor-report): EN + DE strings for section toggles` | +| `64d50fd` | `i18n(doctor-report): EN + DE strings for section toggles` | ## What landed ### Schema (Phase 1) + - `prisma/migrations/0045_doctor_report_prefs/migration.sql` — adds `User.doctor_report_prefs_json JSONB` (nullable). Numbering chosen after W7b's `0044_mood_entry_tz_column`; no conflict. - `prisma/schema.prisma` — adds `doctorReportPrefsJson Json? @map("doctor_report_prefs_json")` with explanatory comment about the privacy default for mood. - `src/lib/validations/doctor-report-prefs.ts` — Zod `doctorReportPrefsSchema` (every key optional), `DEFAULT_DOCTOR_REPORT_PREFS` constant (`mood: false`, everything else ON), `parseDoctorReportPrefs()` + `resolveDoctorReportPrefs()` helpers. - `src/lib/validations/__tests__/doctor-report-prefs.test.ts` — 11 unit tests covering schema validation, partial updates, default-fallback, and forward-compat drift. ### Dialog + persistence (Phase 2) + - `src/components/doctor-report/doctor-report-dialog.tsx` — completely re-styled with a new "Choose sections" group below the practice-name input. Renders one toggle per section, but **only for sections with rows in the chosen date range** (Marc's hide-when-empty rule). Mood gets an inline "Sensitive — off by default" footnote in DE + EN. The dialog also: - Lazy-loads persisted prefs from `GET /api/auth/me/doctor-report-prefs` when first opened. - Re-probes availability on every range change with a generation-counter to prevent stale-write races. @@ -31,6 +33,7 @@ - `src/app/api/doctor-report/availability/route.ts` — new POST endpoint that returns one boolean per section, derived from `count()` queries over the user's data in the requested range. BMI requires both a weight row AND a configured `heightCm`. ### Server-side filtering (Phase 3) + - `src/lib/doctor-report-data.ts` — `collectDoctorReportData()` now accepts `options.sections: Partial`. Privacy contract for mood: when `sections.mood === false`, the aggregator does NOT issue the `MoodEntry.findMany` at all. The data never leaves the DB row. Other sections (BP, weight, pulse, sleep, BMI, compliance) are stripped from the returned payload before render via the new `filterMeasurementKeys()` helper + section-specific clears. - `src/app/api/doctor-report/route.ts` + `src/app/api/doctor-report/pdf/route.ts` — both endpoints parse `body.sections` through `doctorReportPrefsSchema`, default to documented defaults on drift, and forward to the aggregator. Audit log entries now include the rendered `sections` shape so a future privacy review can prove mood was never aggregated when the user opted out. - `tests/integration/doctor-report-sections.test.ts` — 4 integration tests against the real testcontainer Postgres: @@ -40,18 +43,19 @@ 4. Omitted `sections` → documented defaults apply (mood OFF, everything else ON). ### i18n (Phase 4) + - `messages/en.json` + `messages/de.json` — appended `doctorReport.sections.{title, bp, weight, pulse, bmi, mood, moodSensitive, compliance, sleep, empty}` (no restructuring). - `src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx` — added a second i18n-contract assertion block covering all 10 new section-toggle keys in both locales. ## Verification (Phase 5) -| Check | Result | -|-------|--------| -| `pnpm typecheck` | exit 0 | -| `pnpm lint` | clean — only 3 pre-existing unused-vars warnings in untouched files | -| Doctor-report unit tests (6 files, 65 tests) | all green | -| `tests/integration/doctor-report-sections.test.ts` (4 tests) | all green | -| Pre-existing `tests/integration/*.test.ts` (30 files) | all green except 2 unrelated baseline failures (`coach-prefs` from concurrent W5/W7b coach work, `measurements-batch-delete` from a unique-constraint setup issue unrelated to my changes) | +| Check | Result | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pnpm typecheck` | exit 0 | +| `pnpm lint` | clean — only 3 pre-existing unused-vars warnings in untouched files | +| Doctor-report unit tests (6 files, 65 tests) | all green | +| `tests/integration/doctor-report-sections.test.ts` (4 tests) | all green | +| Pre-existing `tests/integration/*.test.ts` (30 files) | all green except 2 unrelated baseline failures (`coach-prefs` from concurrent W5/W7b coach work, `measurements-batch-delete` from a unique-constraint setup issue unrelated to my changes) | ### Unit-test baseline noise @@ -63,8 +67,10 @@ Mood data is **never** fetched when the toggle is off: ```ts sections.mood - ? prisma.moodEntry.findMany({ where: { userId, moodLoggedAt: { gte: start, lte: end } } }) - : Promise.resolve([]) + ? prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: start, lte: end } }, + }) + : Promise.resolve([]); ``` The audit log records the `sections` shape on every report generation, so a future privacy review can prove mood was never aggregated. The integration test `mood: false → PDF does not contain "Stimmung"/"Mood"` is the executable proof. diff --git a/.planning/phase-W7-v1425-timezone-report.md b/.planning/phase-W7-v1425-timezone-report.md index 83ecc1f75..81e73425e 100644 --- a/.planning/phase-W7-v1425-timezone-report.md +++ b/.planning/phase-W7-v1425-timezone-report.md @@ -5,19 +5,19 @@ Status: shipped on `develop`. Not pushed. Not tagged. No ## Per-phase commit SHAs -| Phase | SHA | Summary | -| ----- | --------- | --------------------------------------------------- | +| Phase | SHA | Summary | +| ----- | --------- | ----------------------------------------------------- | | 1 | `7a27390` | Schema additions (migration `0043_per_user_timezone`) | -| 2 | `9e551b7` | Resolver helper + 29-case unit test | -| 3.1 | `96076db` | CSV/JSON export — user-tz offset | -| 3.2 | `3c60852` | `makeFormatters` takes user-tz override | -| 3.3 | `ce6d6be` | Doctor-report PDF timestamps | -| 3.4 | `beb61b7` | OpenAPI doc — date-time contract paragraph | +| 2 | `9e551b7` | Resolver helper + 29-case unit test | +| 3.1 | `96076db` | CSV/JSON export — user-tz offset | +| 3.2 | `3c60852` | `makeFormatters` takes user-tz override | +| 3.3 | `ce6d6be` | Doctor-report PDF timestamps | +| 3.4 | `beb61b7` | OpenAPI doc — date-time contract paragraph | | 4 | `65ce9fd` | Profile timezone picker + `PUT /api/auth/me/timezone` | -| 5 | `26568c1` | Admin server-default timezone setting | -| 6 | `46f2cfb` | Signup captures browser-detected timezone | -| 7 | `78186ba` | Integration test — Pacific/Auckland end-to-end | -| 8a | `a0dffbd` | Prettier on W7 files | +| 5 | `26568c1` | Admin server-default timezone setting | +| 6 | `46f2cfb` | Signup captures browser-detected timezone | +| 7 | `78186ba` | Integration test — Pacific/Auckland end-to-end | +| 8a | `a0dffbd` | Prettier on W7 files | ## Schema deltas @@ -69,18 +69,18 @@ EDT, and Berlin-vs-Auckland midnight day-key roll-over. ## Surfaces touched (proposal §3 inventory) -| # | Surface | Status | File / Line | -| --- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------- | -| 1 | CSV / JSON export | DONE | `src/lib/export.ts:11–125` + `src/app/api/export/{route,measurements,medications,mood}/route.ts` | -| 2 | Reminder cron | ALREADY DONE | `src/lib/jobs/reminder-worker.ts:308` was already user-tz-aware (`med.user.timezone`) | -| 3 | "Today" buckets | DEFERRED (see notes) | `src/app/api/dashboard/summary/route.ts:130`, `src/app/api/analytics/route.ts:330` | -| 4 | `MoodEntry.date` | DOCUMENTED — leave | `src/app/api/mood-entries/route.ts:20` — strategy (a) (read-time interpret) noted | -| 5 | Withings sync | NOT APPLICABLE | `src/lib/withings/sync.ts` stores `measuredAt` as UTC instants, no day-bucket at write | -| 6 | Chart x-axis labels | DEFERRED | `src/components/charts/health-chart.tsx:194` — needs userTz prop threading | -| 7 | Notification body | ALREADY DONE | reminder-worker reads `userTz` from `med.user.timezone` at line 308 already | -| 8 | PDF doctor report | DONE | `src/lib/doctor-report-pdf-core.ts:21–32` + `src/app/api/doctor-report/pdf/route.ts:63–64` | -| 9 | AI Coach context | DEFERRED | `src/lib/ai/coach/snapshot.ts:78–86` buckets by UTC; rewrite is structurally large | -| 10 | OpenAPI / iOS DTO | DONE | `src/lib/openapi/registry.ts:37–43` description block + `docs/api/openapi.yaml` regenerated | +| # | Surface | Status | File / Line | +| --- | ------------------- | -------------------- | ------------------------------------------------------------------------------------------------ | +| 1 | CSV / JSON export | DONE | `src/lib/export.ts:11–125` + `src/app/api/export/{route,measurements,medications,mood}/route.ts` | +| 2 | Reminder cron | ALREADY DONE | `src/lib/jobs/reminder-worker.ts:308` was already user-tz-aware (`med.user.timezone`) | +| 3 | "Today" buckets | DEFERRED (see notes) | `src/app/api/dashboard/summary/route.ts:130`, `src/app/api/analytics/route.ts:330` | +| 4 | `MoodEntry.date` | DOCUMENTED — leave | `src/app/api/mood-entries/route.ts:20` — strategy (a) (read-time interpret) noted | +| 5 | Withings sync | NOT APPLICABLE | `src/lib/withings/sync.ts` stores `measuredAt` as UTC instants, no day-bucket at write | +| 6 | Chart x-axis labels | DEFERRED | `src/components/charts/health-chart.tsx:194` — needs userTz prop threading | +| 7 | Notification body | ALREADY DONE | reminder-worker reads `userTz` from `med.user.timezone` at line 308 already | +| 8 | PDF doctor report | DONE | `src/lib/doctor-report-pdf-core.ts:21–32` + `src/app/api/doctor-report/pdf/route.ts:63–64` | +| 9 | AI Coach context | DEFERRED | `src/lib/ai/coach/snapshot.ts:78–86` buckets by UTC; rewrite is structurally large | +| 10 | OpenAPI / iOS DTO | DONE | `src/lib/openapi/registry.ts:37–43` description block + `docs/api/openapi.yaml` regenerated | ### Deferred surfaces — why and what next @@ -128,14 +128,14 @@ acceptable forever. ## Test deltas -| Suite | Before | After | Delta | -| ---------------------------------- | ------ | ----- | ----- | -| `src/lib/tz/__tests__/` | n/a | 29 | +29 | -| `src/lib/__tests__/export.test.ts` | 9 | 14 | +5 | -| `src/lib/__tests__/format-locale.test.ts` | 9 | 12 | +3 | -| `src/app/api/auth/me/timezone/__tests__/` | n/a | 6 | +6 | -| `src/app/api/admin/settings/__tests__/` | 16 | 19 | +3 | -| Integration: `tests/integration/timezone-per-user.test.ts` | n/a | 7 | +7 | +| Suite | Before | After | Delta | +| ---------------------------------------------------------- | ------ | ----- | ----- | +| `src/lib/tz/__tests__/` | n/a | 29 | +29 | +| `src/lib/__tests__/export.test.ts` | 9 | 14 | +5 | +| `src/lib/__tests__/format-locale.test.ts` | 9 | 12 | +3 | +| `src/app/api/auth/me/timezone/__tests__/` | n/a | 6 | +6 | +| `src/app/api/admin/settings/__tests__/` | 16 | 19 | +3 | +| Integration: `tests/integration/timezone-per-user.test.ts` | n/a | 7 | +7 | **Unit total: +46.** Suite ran clean at 2292 / 2292. @@ -147,14 +147,14 @@ invalid. ## CI gates -| Gate | Result | -| -------------------------- | ------ | -| `pnpm typecheck` | clean | -| `pnpm lint` | clean | -| `pnpm test` (unit) | 2292 passed | -| `pnpm test:integration` (new file) | 7 passed | -| `pnpm openapi:check` | in sync | -| `pnpm format:check` (W7 files) | clean (7 pre-existing planning-doc warnings from other agents remain) | +| Gate | Result | +| ---------------------------------- | --------------------------------------------------------------------- | +| `pnpm typecheck` | clean | +| `pnpm lint` | clean | +| `pnpm test` (unit) | 2292 passed | +| `pnpm test:integration` (new file) | 7 passed | +| `pnpm openapi:check` | in sync | +| `pnpm format:check` (W7 files) | clean (7 pre-existing planning-doc warnings from other agents remain) | ## Files touched @@ -244,6 +244,7 @@ tests/integration/timezone-per-user.test.ts # NEW (~280 LOC) ## How a Warsaw user (the issue #167 trigger) experiences the fix Before W7: + ``` $ curl -H "Cookie: …" /api/export/measurements type,value,unit,measuredAt,source,notes,glucoseContext @@ -254,6 +255,7 @@ WEIGHT,80,kg,2026-05-15T09:00:00.000Z,MANUAL,, ``` After W7 (Warsaw user with `User.timezone = "Europe/Warsaw"`): + ``` $ curl -H "Cookie: …" /api/export/measurements type,value,unit,measuredAt,source,notes,glucoseContext diff --git a/.planning/phase-W7b-v1425-timezone-followup-report.md b/.planning/phase-W7b-v1425-timezone-followup-report.md index 6da2e7acf..183a834d4 100644 --- a/.planning/phase-W7b-v1425-timezone-followup-report.md +++ b/.planning/phase-W7b-v1425-timezone-followup-report.md @@ -12,13 +12,13 @@ those. ## Per-surface commit SHAs -| Surface | SHA | Summary | -| ------- | --------- | ------------------------------------------------------- | -| 1 | `35f068a` | `berlinDayKey()` callers honour user `displayTimezone` | -| 2 | `eeaa563` | Chart x-axis ticks render in user `displayTimezone` | -| 3 | `7d878c1` | Coach snapshot anchors yesterday/this-week to user tz | -| 4 | `989243a` | `MoodEntry.tz` column for per-row tz attribution | -| — | `2bdfdc2` | Prettier across W7b surfaces | +| Surface | SHA | Summary | +| ------- | --------- | ------------------------------------------------------ | +| 1 | `35f068a` | `berlinDayKey()` callers honour user `displayTimezone` | +| 2 | `eeaa563` | Chart x-axis ticks render in user `displayTimezone` | +| 3 | `7d878c1` | Coach snapshot anchors yesterday/this-week to user tz | +| 4 | `989243a` | `MoodEntry.tz` column for per-row tz attribution | +| — | `2bdfdc2` | Prettier across W7b surfaces | ## Surface 1 — `berlinDayKey()` 5 callers @@ -122,10 +122,10 @@ would land on the Berlin day. +8 unit tests. ## Test deltas -| Suite | Before | After | Delta | -| ----------------------------------------------------------- | ------ | ----- | ----- | -| Unit total | 2367 | 2375 | +8 | -| Integration: `tests/integration/timezone-per-user.test.ts` | 7 | 10 | +3 | +| Suite | Before | After | Delta | +| ---------------------------------------------------------- | ------ | ----- | ----- | +| Unit total | 2367 | 2375 | +8 | +| Integration: `tests/integration/timezone-per-user.test.ts` | 7 | 10 | +3 | Unit `+8` is `src/lib/mood/__tests__/date-key.test.ts` (Surface 4). Integration `+3` is two assertions added in Surface 1 and one in @@ -133,13 +133,13 @@ Surface 3. ## CI gates -| Gate | Result | -| ------------------------------------------ | ------------------------------- | -| `pnpm typecheck` | clean | -| `pnpm lint` | clean | -| `pnpm test` (unit) | 2375 passed, 1 skipped | -| `pnpm test:integration` (timezone file) | 10 passed | -| `pnpm format:check` (W7b files) | clean after `pnpm format` run | +| Gate | Result | +| --------------------------------------- | ----------------------------- | +| `pnpm typecheck` | clean | +| `pnpm lint` | clean | +| `pnpm test` (unit) | 2375 passed, 1 skipped | +| `pnpm test:integration` (timezone file) | 10 passed | +| `pnpm format:check` (W7b files) | clean after `pnpm format` run | A single unrelated integration test from a concurrent agent's branch (`tests/integration/measurements-batch-delete.test.ts`, introduced in diff --git a/.planning/phase-W9-v1425-repo-polish-report.md b/.planning/phase-W9-v1425-repo-polish-report.md index a73501f5d..6ac4a31fc 100644 --- a/.planning/phase-W9-v1425-repo-polish-report.md +++ b/.planning/phase-W9-v1425-repo-polish-report.md @@ -245,7 +245,7 @@ documented in the checklist as a 1-hour Marc action. **Applied** to `main`: ```yaml -required_status_checks: null # deliberately deferred to W10 +required_status_checks: null # deliberately deferred to W10 enforce_admins: false required_pull_request_reviews: null restrictions: null @@ -253,7 +253,7 @@ required_linear_history: false allow_force_pushes: false allow_deletions: false block_creations: false -required_conversation_resolution: true # NEW in v2 +required_conversation_resolution: true # NEW in v2 lock_branch: false allow_fork_syncing: false ``` @@ -317,16 +317,16 @@ true ## Deferred to v1.4.26 / awaiting Marc -| Item | Reason | -| --- | --- | -| Merge PRs #163 / #164 / #166 | gh token lacks `workflow` scope; UI merge or re-auth | -| Rebase + dual-bump react+react-dom (#155) | Wait for next Dependabot sweep | -| Upload social-preview OG image | UI-only action; needs Marc to capture + upload | -| Hero / 3-screenshot strip in README | Needs scrubbed `demo.healthlog.dev` screenshots | -| `.github/FUNDING.yml` | Awaiting Marc approval on funding pitch | -| Pinned issues + good-first-issue seed | Post-v1.5 action | +| Item | Reason | +| ----------------------------------------------- | ---------------------------------------------------- | +| Merge PRs #163 / #164 / #166 | gh token lacks `workflow` scope; UI merge or re-auth | +| Rebase + dual-bump react+react-dom (#155) | Wait for next Dependabot sweep | +| Upload social-preview OG image | UI-only action; needs Marc to capture + upload | +| Hero / 3-screenshot strip in README | Needs scrubbed `demo.healthlog.dev` screenshots | +| `.github/FUNDING.yml` | Awaiting Marc approval on funding pitch | +| Pinned issues + good-first-issue seed | Post-v1.5 action | | Add `required_status_checks` to main protection | W10 owns the prerequisite e2e + integration green-up | -| Star-history + "as featured in" README badges | Post awesome-selfhosted PR acceptance | +| Star-history + "as featured in" README badges | Post awesome-selfhosted PR acceptance | --- diff --git a/.planning/research/withings-api-coverage.md b/.planning/research/withings-api-coverage.md index db18f4b5e..00a3a9bd7 100644 --- a/.planning/research/withings-api-coverage.md +++ b/.planning/research/withings-api-coverage.md @@ -12,25 +12,25 @@ This document audits **every** Withings public API endpoint and meastype against Current ingestion path (`src/lib/withings/client.ts` → `MEASURE_TYPE_MAP`): -| meastype | Withings name | HealthLog `MeasurementType` | Unit (DB) | Notes | -|---|---|---|---|---| -| 1 | Weight | `WEIGHT` | kg | ✅ Body+, Body Cardio, Body Comp, Body Scan | -| 6 | Fat Ratio | `BODY_FAT` | % | ✅ Body+ family | -| 9 | Diastolic Blood Pressure | `BLOOD_PRESSURE_DIA` | mmHg | ✅ BPM Connect, BPM Core, BPM Vision | -| 10 | Systolic Blood Pressure | `BLOOD_PRESSURE_SYS` | mmHg | ✅ same | -| 11 | Heart Pulse | `PULSE` | bpm | ✅ from BPM cuffs + scales | -| 54 | SpO2 (ScanWatch / pulse-ox) | `OXYGEN_SATURATION` | % | ✅ ScanWatch / BPM Vision | -| 77 | Hydration / Water Mass | `TOTAL_BODY_WATER` | kg | ✅ Body Comp, Body Scan (kg of water) | -| 88 | Bone Mass | `BONE_MASS` | kg | ✅ Body Comp, Body Scan | +| meastype | Withings name | HealthLog `MeasurementType` | Unit (DB) | Notes | +| -------- | --------------------------- | --------------------------- | --------- | ------------------------------------------- | +| 1 | Weight | `WEIGHT` | kg | ✅ Body+, Body Cardio, Body Comp, Body Scan | +| 6 | Fat Ratio | `BODY_FAT` | % | ✅ Body+ family | +| 9 | Diastolic Blood Pressure | `BLOOD_PRESSURE_DIA` | mmHg | ✅ BPM Connect, BPM Core, BPM Vision | +| 10 | Systolic Blood Pressure | `BLOOD_PRESSURE_SYS` | mmHg | ✅ same | +| 11 | Heart Pulse | `PULSE` | bpm | ✅ from BPM cuffs + scales | +| 54 | SpO2 (ScanWatch / pulse-ox) | `OXYGEN_SATURATION` | % | ✅ ScanWatch / BPM Vision | +| 77 | Hydration / Water Mass | `TOTAL_BODY_WATER` | kg | ✅ Body Comp, Body Scan (kg of water) | +| 88 | Bone Mass | `BONE_MASS` | kg | ✅ Body Comp, Body Scan | Endpoints actually used: -| Endpoint | Action | Status | -|---|---|---| -| `POST /v2/oauth2` | `requesttoken` (exchange + refresh) | ✅ implemented | -| `POST /measure` | `getmeas` | ✅ implemented (paged with `offset` until `more=false`) | -| `POST /notify` | `subscribe` (appli=1) | ✅ implemented — but ONLY appli=1 (weight) | -| `POST /notify` | `revoke` | ✅ implemented | +| Endpoint | Action | Status | +| ----------------- | ----------------------------------- | ------------------------------------------------------- | +| `POST /v2/oauth2` | `requesttoken` (exchange + refresh) | ✅ implemented | +| `POST /measure` | `getmeas` | ✅ implemented (paged with `offset` until `more=false`) | +| `POST /notify` | `subscribe` (appli=1) | ✅ implemented — but ONLY appli=1 (weight) | +| `POST /notify` | `revoke` | ✅ implemented | OAuth scope requested: `user.metrics` — see `getAuthorizationUrl()`. That's the right minimum for measurements; it does NOT cover activity (`users.activity`), sleep events (`user.sleepevents`), or user info (`user.info`). @@ -44,47 +44,47 @@ Withings' core "scalar reading" endpoint. Returns `measuregrps` keyed by `measty Coverage legend: ✅ ingested today / 🟡 enum exists but not wired / ⚠️ wire to existing bucket / ❌ no enum, would need additive schema change. -| meastype | Withings name | Unit (raw) | HealthLog status | Target bucket | Notes | -|---|---|---|---|---|---| -| 1 | Weight | kg | ✅ ingested | `WEIGHT` | | -| 4 | Height | m | ❌ no enum | (User profile field) | One-shot. Belongs in `User` profile (display unit), not `Measurement`. Skip-with-rationale. | -| 5 | Fat Free Mass | kg | ❌ no enum | (new `FAT_FREE_MASS`) | Body+ family ships this. Algorithmically = weight − fat mass. Defer to v1.4.26 with schema enum addition. | -| 6 | Fat Ratio (body fat %) | % | ✅ ingested | `BODY_FAT` | | -| 8 | Fat Mass Weight | kg | ❌ no enum | (new `FAT_MASS`) | Body+ family. Mass form of `BODY_FAT` (kg, not %). Defer. | -| 9 | Diastolic BP | mmHg | ✅ ingested | `BLOOD_PRESSURE_DIA` | | -| 10 | Systolic BP | mmHg | ✅ ingested | `BLOOD_PRESSURE_SYS` | | -| 11 | Heart Pulse | bpm | ✅ ingested | `PULSE` | From BPM cuff + scale. Scale = standing HR. | -| 12 | Temperature (legacy Thermo) | °C | 🟡 enum exists | `BODY_TEMPERATURE` | **Wire in v1.4.25.** First-gen Thermo (WBT01). | -| 35 | SpO2 (legacy code) | % | 🟡 enum exists | `OXYGEN_SATURATION` | **Wire in v1.4.25.** Some Withings devices report `35` instead of `54`. | -| 54 | SpO2 (ScanWatch / pulse-ox) | % | ✅ ingested | `OXYGEN_SATURATION` | | -| 71 | Body Temperature (Thermo / core) | °C | 🟡 enum exists | `BODY_TEMPERATURE` | **Wire in v1.4.25.** Current-gen Thermo. | -| 73 | Skin Temperature | °C | ❌ no enum | (new `SKIN_TEMPERATURE`) | ScanWatch dermal reading; distinct from core body temp. Defer to v1.4.26 with new enum (collapsing into `BODY_TEMPERATURE` would corrupt analytics — surface temps run 30–34 °C, core ≈37 °C). | -| 76 | Muscle Mass | kg | ❌ no enum | (new `MUSCLE_MASS`) | Body+ family. Defer to v1.4.26. | -| 77 | Hydration / Water Mass | kg | ✅ ingested | `TOTAL_BODY_WATER` | | -| 88 | Bone Mass | kg | ✅ ingested | `BONE_MASS` | | -| 91 | Pulse Wave Velocity | m/s | ❌ no enum | (new `PULSE_WAVE_VELOCITY`) | Body Cardio / Body Scan exclusive. Not in US. Defer to v1.4.26. | -| 123 | VO2 max | mL/(kg·min) | 🟡 enum exists | `VO2_MAX` | **Wire in v1.4.25.** ScanWatch series. | -| 130 | Atrial Fibrillation (PPG) | enum 0/1 | ❌ no enum | (defer — needs event model) | Algorithmic AFib flag from PPG window. Defer to v1.5 with a dedicated `CardiacEvent` model. | -| 135 | QRS interval (auto from ECG) | ms | ❌ no enum | (defer — clinical) | ScanWatch / BPM Vision. Defer to v1.5. | -| 136 | PR interval | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | -| 137 | QT interval | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | -| 138 | Corrected QT (QTc) | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | -| 139 | AFib classification result | enum | ❌ no enum | (defer — clinical event) | Defer to v1.5. | -| 155 | Vascular Age | years | ❌ no enum | (new `VASCULAR_AGE`) | Body Scan. Defer to v1.4.26. | -| 167 | Nerve Health Score | 0–100 | ❌ no enum | (new) | Body Comp / Body Scan, US-only via Total Biomarker Pack. Defer to v1.4.26 — niche. | -| 168 | Extracellular Water | L | ❌ no enum | (new) | Body Scan exclusive. Defer to v1.5 — segmental composition is a sub-feature. | -| 169 | Intracellular Water | L | ❌ no enum | (new) | Same. | -| 170 | Visceral Fat | rating | ❌ no enum | (new `VISCERAL_FAT`) | Body Comp / Body Scan. Defer to v1.4.26. | -| 174 | Fat Mass Segmental | kg per limb | ❌ no enum | (defer — JSON sub-doc) | Body Scan segmental. Defer to v1.5. | -| 175 | Muscle Mass Segmental | kg per limb | ❌ no enum | (defer — JSON sub-doc) | Same. | -| 196 | Electrodermal Activity | µS | ❌ no enum | (defer) | Nerve-health module. Defer to v1.5. | -| 226 | Lean Mass | kg | ❌ no enum | (alias of 5?) | Same semantics as Fat Free Mass — Withings ships both; verify before mapping. Defer. | +| meastype | Withings name | Unit (raw) | HealthLog status | Target bucket | Notes | +| -------- | -------------------------------- | ----------- | ---------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Weight | kg | ✅ ingested | `WEIGHT` | | +| 4 | Height | m | ❌ no enum | (User profile field) | One-shot. Belongs in `User` profile (display unit), not `Measurement`. Skip-with-rationale. | +| 5 | Fat Free Mass | kg | ❌ no enum | (new `FAT_FREE_MASS`) | Body+ family ships this. Algorithmically = weight − fat mass. Defer to v1.4.26 with schema enum addition. | +| 6 | Fat Ratio (body fat %) | % | ✅ ingested | `BODY_FAT` | | +| 8 | Fat Mass Weight | kg | ❌ no enum | (new `FAT_MASS`) | Body+ family. Mass form of `BODY_FAT` (kg, not %). Defer. | +| 9 | Diastolic BP | mmHg | ✅ ingested | `BLOOD_PRESSURE_DIA` | | +| 10 | Systolic BP | mmHg | ✅ ingested | `BLOOD_PRESSURE_SYS` | | +| 11 | Heart Pulse | bpm | ✅ ingested | `PULSE` | From BPM cuff + scale. Scale = standing HR. | +| 12 | Temperature (legacy Thermo) | °C | 🟡 enum exists | `BODY_TEMPERATURE` | **Wire in v1.4.25.** First-gen Thermo (WBT01). | +| 35 | SpO2 (legacy code) | % | 🟡 enum exists | `OXYGEN_SATURATION` | **Wire in v1.4.25.** Some Withings devices report `35` instead of `54`. | +| 54 | SpO2 (ScanWatch / pulse-ox) | % | ✅ ingested | `OXYGEN_SATURATION` | | +| 71 | Body Temperature (Thermo / core) | °C | 🟡 enum exists | `BODY_TEMPERATURE` | **Wire in v1.4.25.** Current-gen Thermo. | +| 73 | Skin Temperature | °C | ❌ no enum | (new `SKIN_TEMPERATURE`) | ScanWatch dermal reading; distinct from core body temp. Defer to v1.4.26 with new enum (collapsing into `BODY_TEMPERATURE` would corrupt analytics — surface temps run 30–34 °C, core ≈37 °C). | +| 76 | Muscle Mass | kg | ❌ no enum | (new `MUSCLE_MASS`) | Body+ family. Defer to v1.4.26. | +| 77 | Hydration / Water Mass | kg | ✅ ingested | `TOTAL_BODY_WATER` | | +| 88 | Bone Mass | kg | ✅ ingested | `BONE_MASS` | | +| 91 | Pulse Wave Velocity | m/s | ❌ no enum | (new `PULSE_WAVE_VELOCITY`) | Body Cardio / Body Scan exclusive. Not in US. Defer to v1.4.26. | +| 123 | VO2 max | mL/(kg·min) | 🟡 enum exists | `VO2_MAX` | **Wire in v1.4.25.** ScanWatch series. | +| 130 | Atrial Fibrillation (PPG) | enum 0/1 | ❌ no enum | (defer — needs event model) | Algorithmic AFib flag from PPG window. Defer to v1.5 with a dedicated `CardiacEvent` model. | +| 135 | QRS interval (auto from ECG) | ms | ❌ no enum | (defer — clinical) | ScanWatch / BPM Vision. Defer to v1.5. | +| 136 | PR interval | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | +| 137 | QT interval | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | +| 138 | Corrected QT (QTc) | ms | ❌ no enum | (defer — clinical) | Defer to v1.5. | +| 139 | AFib classification result | enum | ❌ no enum | (defer — clinical event) | Defer to v1.5. | +| 155 | Vascular Age | years | ❌ no enum | (new `VASCULAR_AGE`) | Body Scan. Defer to v1.4.26. | +| 167 | Nerve Health Score | 0–100 | ❌ no enum | (new) | Body Comp / Body Scan, US-only via Total Biomarker Pack. Defer to v1.4.26 — niche. | +| 168 | Extracellular Water | L | ❌ no enum | (new) | Body Scan exclusive. Defer to v1.5 — segmental composition is a sub-feature. | +| 169 | Intracellular Water | L | ❌ no enum | (new) | Same. | +| 170 | Visceral Fat | rating | ❌ no enum | (new `VISCERAL_FAT`) | Body Comp / Body Scan. Defer to v1.4.26. | +| 174 | Fat Mass Segmental | kg per limb | ❌ no enum | (defer — JSON sub-doc) | Body Scan segmental. Defer to v1.5. | +| 175 | Muscle Mass Segmental | kg per limb | ❌ no enum | (defer — JSON sub-doc) | Same. | +| 196 | Electrodermal Activity | µS | ❌ no enum | (defer) | Nerve-health module. Defer to v1.5. | +| 226 | Lean Mass | kg | ❌ no enum | (alias of 5?) | Same semantics as Fat Free Mass — Withings ships both; verify before mapping. Defer. | The `getmeas` call we already make sends `meastypes=1,6,9,10,11,54,77,88` (the keys of `MEASURE_TYPE_MAP`). To pick up the v1.4.25 additions we just extend the map; the query string grows automatically. **Unit semantics** — Withings encodes every value as `value * 10^unit` where `unit` is the decimal exponent (e.g. `value=65750, unit=-3` → `65.750 kg`). The existing client already does this `m.value * Math.pow(10, m.unit)`. No new conversion logic needed for v1.4.25 wires — all four additions are SI / dimensionless and inherit the exponent rule. -**`category` parameter** — undocumented for new additions; we leave `category` unset, which returns *real measures* (not goals). No change needed. +**`category` parameter** — undocumented for new additions; we leave `category` unset, which returns _real measures_ (not goals). No change needed. ### 2.2 Sleep API — `POST /v2/sleep` @@ -92,28 +92,29 @@ Two actions: `get` (high-frequency series, ≤24h window) and `getsummary` (per- `getsummary` data fields (DB targets we'd need): -| Field | Description | HealthLog target | -|---|---|---| -| `total_sleep_time` | Total sleep, sec | `SLEEP_DURATION` (minutes; convert) | -| `total_timeinbed` | Total in bed, sec | sleepStage `IN_BED` row | -| `asleepduration` | Total asleep, sec | sleepStage `ASLEEP` row | -| `lightsleepduration` | Light sleep, sec | sleepStage `CORE` row (HealthKit names "Core" = light) | -| `deepsleepduration` | Deep sleep, sec | sleepStage `DEEP` | -| `remsleepduration` | REM, sec | sleepStage `REM` | -| `wakeupduration` | Wake during night, sec | sleepStage `AWAKE` | -| `wakeupcount` | Number of wake-ups | (no slot — defer) | -| `durationtosleep` | Sleep latency, sec | (no slot — defer) | -| `durationtowakeup` | Wakeup latency, sec | (no slot — defer) | -| `sleep_score` | 0–100 | (no slot — surface in UI as raw or store as `WithingsSleepScore` later) | -| `hr_average` / `hr_min` / `hr_max` | bpm | (no slot — defer) | -| `rr_average` | breaths/min | (no slot — defer) | -| `snoring` | sec | (no slot — defer) | -| `breathing_disturbances_intensity` | severity 0–100 | (no slot — apnea screening signal; defer) | -| `apnea_hypopnea_index` | events/h | (no slot — defer; clinical) | -| `sdnn_1` (sleep block 1) | ms | `HEART_RATE_VARIABILITY` (per-night agg) | -| `rmssd` | ms | (alternative HRV metric — store under same enum or add `RMSSD`?) | +| Field | Description | HealthLog target | +| ---------------------------------- | ---------------------- | ----------------------------------------------------------------------- | +| `total_sleep_time` | Total sleep, sec | `SLEEP_DURATION` (minutes; convert) | +| `total_timeinbed` | Total in bed, sec | sleepStage `IN_BED` row | +| `asleepduration` | Total asleep, sec | sleepStage `ASLEEP` row | +| `lightsleepduration` | Light sleep, sec | sleepStage `CORE` row (HealthKit names "Core" = light) | +| `deepsleepduration` | Deep sleep, sec | sleepStage `DEEP` | +| `remsleepduration` | REM, sec | sleepStage `REM` | +| `wakeupduration` | Wake during night, sec | sleepStage `AWAKE` | +| `wakeupcount` | Number of wake-ups | (no slot — defer) | +| `durationtosleep` | Sleep latency, sec | (no slot — defer) | +| `durationtowakeup` | Wakeup latency, sec | (no slot — defer) | +| `sleep_score` | 0–100 | (no slot — surface in UI as raw or store as `WithingsSleepScore` later) | +| `hr_average` / `hr_min` / `hr_max` | bpm | (no slot — defer) | +| `rr_average` | breaths/min | (no slot — defer) | +| `snoring` | sec | (no slot — defer) | +| `breathing_disturbances_intensity` | severity 0–100 | (no slot — apnea screening signal; defer) | +| `apnea_hypopnea_index` | events/h | (no slot — defer; clinical) | +| `sdnn_1` (sleep block 1) | ms | `HEART_RATE_VARIABILITY` (per-night agg) | +| `rmssd` | ms | (alternative HRV metric — store under same enum or add `RMSSD`?) | **v1.4.26 candidate.** Wiring this needs: + 1. A new sync routine `syncUserSleep(userId, opts)` that calls `POST /v2/sleep?action=getsummary`. 2. `getmeas` and `getsummary` cannot share the same request because the two endpoints take different action verbs. 3. Subscription `appli=44` (User Sleep) to drive webhook updates. @@ -124,6 +125,7 @@ Two actions: `get` (high-frequency series, ≤24h window) and `getsummary` (per- Two actions: `list` (per-ECG-record metadata + AFib classification) and `get` (raw ECG signal in µV at 500 Hz). A `list` response item looks like: + ```json { "ecg": { "signalid": 48, "afib": 1 }, @@ -139,30 +141,32 @@ A `list` response item looks like: Three actions: -| Action | Returns | Auth scope | -|---|---|---| -| `getactivity` | per-day aggregates: steps, distance (m), elevation, soft/moderate/intense sec, active sec, calories, totalcalories, hr_average/min/max, hr_zone_0..3 sec | `users.activity` | -| `getintradayactivity` | high-freq per-minute samples of the same fields | `users.activity` | -| `getworkouts` | discrete workout sessions with start/end + per-session aggregates + sport-specific fields (pool laps, strokes, GPS) | `users.activity` | +| Action | Returns | Auth scope | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `getactivity` | per-day aggregates: steps, distance (m), elevation, soft/moderate/intense sec, active sec, calories, totalcalories, hr_average/min/max, hr_zone_0..3 sec | `users.activity` | +| `getintradayactivity` | high-freq per-minute samples of the same fields | `users.activity` | +| `getworkouts` | discrete workout sessions with start/end + per-session aggregates + sport-specific fields (pool laps, strokes, GPS) | `users.activity` | **Mapping potential** (with `users.activity` scope added to OAuth): + - `steps` → `ACTIVITY_STEPS` (enum exists; Apple Health path already uses it) - `distance` → `WALKING_RUNNING_DISTANCE` (enum exists) -- `elevation` → `FLIGHTS_CLIMBED` (enum exists, but Withings unit is *floors*; verify — Withings docs list `elevation: Number of floors climbed`. Match!) +- `elevation` → `FLIGHTS_CLIMBED` (enum exists, but Withings unit is _floors_; verify — Withings docs list `elevation: Number of floors climbed`. Match!) - `calories` → `ACTIVE_ENERGY_BURNED` (enum exists) - `hr_average` per day → (no enum; could use `RESTING_HEART_RATE` if marked as "rest"; skip for now) **v1.4.26 candidate.** Wiring needs: + 1. Scope upgrade: append `users.activity` to `getAuthorizationUrl()` scope string. **Breaking change for existing connections** — they must reconnect. Mitigation: trigger reauth flow with messaging "we've added activity sync; reconnect to enable." 2. New sync routine `syncUserActivity(userId)`. 3. Workouts need a brand-new `Workout` model — defer that piece to v1.5. ### 2.5 User API — `POST /v2/user` -| Action | Returns | -|---|---| -| `getdevice` | List of user's devices: type, model, model_id, last activation, firmware version, battery | -| `getbyuserid` | Internal user info (probably gated to dropshipment partners) | +| Action | Returns | +| ------------- | ----------------------------------------------------------------------------------------- | +| `getdevice` | List of user's devices: type, model, model_id, last activation, firmware version, battery | +| `getbyuserid` | Internal user info (probably gated to dropshipment partners) | **v1.5 candidate.** A "Connected Withings devices" badge under Settings → Integrations is a nice polish item — surfaces which physical device the data came from. Not in v1.4.25 critical path. @@ -170,22 +174,22 @@ Three actions: Subscribe / list / revoke / get. Subscribing requires the user's access token. The `appli` integer is the **category we want to be notified about**: -| appli | Trigger | Scope | Service to call back | -|---|---|---|---| -| 1 | New weight/composition meas (types 1, 5, 6, 8, 88) | `user.metrics` | `Measure-Getmeas` | -| 2 | New temperature meas (12, 71, 73) | `user.metrics` | `Measure-Getmeas` | -| 4 | New BP/SpO2 meas (9, 10, 11, 54) | `user.metrics` | `Measure-Getmeas` | -| 16 | New activity data | `users.activity` | `getactivity` / `getintraday` / `getworkouts` | -| 44 | New sleep summary | `users.activity` | `Sleep v2-Get` / `Getsummary` | -| 46 | User profile changed (delete / unlink / update) | `user.info` | (no fetch — react to event) | -| 50 | Bed in event | `user.sleepevents` | (no fetch) | -| 51 | Bed out event | `user.sleepevents` | (no fetch) | -| 52 | Sleep sensor inflate done | `user.sleepevents` | (no fetch) | -| 53 | No account associated (cellular device handoff) | n/a | `User v2-Link` | -| 54 | New ECG data | `user.metrics` | `Heart v2-List` | -| 55 | ECG measure failed | `user.metrics` | (no fetch) | -| 58 | New glucose meas | `user.metrics` | `Measure-Getmeas` | -| 119 | New glucose (alternate code per partner package) | `user.metrics` | `Measure-Getmeas` | +| appli | Trigger | Scope | Service to call back | +| ----- | -------------------------------------------------- | ------------------ | --------------------------------------------- | +| 1 | New weight/composition meas (types 1, 5, 6, 8, 88) | `user.metrics` | `Measure-Getmeas` | +| 2 | New temperature meas (12, 71, 73) | `user.metrics` | `Measure-Getmeas` | +| 4 | New BP/SpO2 meas (9, 10, 11, 54) | `user.metrics` | `Measure-Getmeas` | +| 16 | New activity data | `users.activity` | `getactivity` / `getintraday` / `getworkouts` | +| 44 | New sleep summary | `users.activity` | `Sleep v2-Get` / `Getsummary` | +| 46 | User profile changed (delete / unlink / update) | `user.info` | (no fetch — react to event) | +| 50 | Bed in event | `user.sleepevents` | (no fetch) | +| 51 | Bed out event | `user.sleepevents` | (no fetch) | +| 52 | Sleep sensor inflate done | `user.sleepevents` | (no fetch) | +| 53 | No account associated (cellular device handoff) | n/a | `User v2-Link` | +| 54 | New ECG data | `user.metrics` | `Heart v2-List` | +| 55 | ECG measure failed | `user.metrics` | (no fetch) | +| 58 | New glucose meas | `user.metrics` | `Measure-Getmeas` | +| 119 | New glucose (alternate code per partner package) | `user.metrics` | `Measure-Getmeas` | **Gap:** HealthLog only subscribes to `appli=1`. **Even today**, BP readings (meastype 9/10/11) and SpO2 (54) flow in via the hourly poll, not via webhook — because they live behind `appli=4`, not `appli=1`. This is an **unflagged latency bug**: a new BP reading takes up to an hour to surface in HealthLog. Fix-in-v1.4.25 candidate (cheap; just subscribe to `appli=4` as well). @@ -194,13 +198,14 @@ For v1.4.25 we add `appli=2` (temperature) and `appli=4` (BP/SpO2) subscriptions ### 2.7 Notes on Withings+ subscription None of the endpoints in §2.1 – §2.6 require a Withings+ subscription on the user's side. Coverage is purely a function of: + 1. **Withings developer app permissions** — set per-app in the partner hub. 2. **OAuth scope** — must include `users.activity` / `user.sleepevents` / `user.info` for the corresponding endpoints. 3. **Hardware** — the user needs the device that produces the metric (ScanWatch for SpO2, Body Scan for segmental composition, etc.). The Withings+ tier (`Cardio Check-Up`, `Health Improvement Score`, `Vitality Indicator`) sits **above** the API layer — those features are computed inside Withings' app and are not exposed by the public API. There's nothing to "ingest" from Withings+ even if the user pays. See `.planning/research/withings-plus-comparison.md` §1.2. -**This is good news.** Marketing copy can honestly say: *"HealthLog reads everything a Withings device records, regardless of whether you subscribe to Withings+."* +**This is good news.** Marketing copy can honestly say: _"HealthLog reads everything a Withings device records, regardless of whether you subscribe to Withings+."_ --- @@ -208,31 +213,31 @@ The Withings+ tier (`Cardio Check-Up`, `Health Improvement Score`, `Vitality Ind Scale: User-value (UV) 5=most users / 1=niche. Effort (E) 5=trivial / 1=multi-day. Schema-impact (S) 5=none / 1=new model needed. -| Gap | UV | E | S | Wave | Rationale | -|---|---|---|---|---|---| -| meastype 12 Temperature (legacy) | 2 | 5 | 5 | **v1.4.25** | One line in `MEASURE_TYPE_MAP`. Enum already has `BODY_TEMPERATURE`. | -| meastype 71 Body Temperature (Thermo) | 3 | 5 | 5 | **v1.4.25** | Same. Thermo owners get fever tracking sync. | -| meastype 35 SpO2 (alt code) | 3 | 5 | 5 | **v1.4.25** | Same. Some devices report 35 instead of 54. | -| meastype 123 VO2 max | 3 | 5 | 5 | **v1.4.25** | Same. Enum already has `VO2_MAX`. | -| Subscribe appli=4 (BP webhook) | 5 | 5 | 5 | **v1.4.25** | Latency bug fix. BP currently waits an hour; should be seconds. | -| Subscribe appli=2 (temperature webhook) | 2 | 5 | 5 | **v1.4.25** | Cheap; matches the new meastype 12/71 mapping. | -| meastype 73 Skin Temperature | 2 | 4 | 3 | v1.4.26 | Needs new `SKIN_TEMPERATURE` enum (different physiology than core). | -| meastype 5/8/76 Body composition (FFM / FM / Muscle) | 4 | 4 | 3 | v1.4.26 | Three new enum values + i18n strings + chart slots. Body Comp / Body Scan owners care. | -| meastype 170 Visceral Fat | 3 | 4 | 3 | v1.4.26 | New enum slot. Body Comp / Body Scan. | -| meastype 91 Pulse Wave Velocity | 3 | 4 | 3 | v1.4.26 | New enum slot. Body Cardio / Body Scan, EU+US only. | -| meastype 155 Vascular Age | 2 | 4 | 3 | v1.4.26 | New enum slot. Composite of PWV + age — surfaces a "year" value. | -| Sleep API (getsummary) — duration + stages + HRV | 5 | 3 | 4 | v1.4.26 | New sync routine; uses existing `SLEEP_DURATION`/`HEART_RATE_VARIABILITY` enums + `SleepStage`. Pairs with W4-shipped sleep chart. | -| Activity API (getactivity) — steps + distance + calories | 4 | 3 | 5 | v1.4.26 | Existing enums; scope upgrade requires user reauth. | -| Workouts API (getworkouts) | 3 | 2 | 1 | v1.5 | Needs `Workout` model with sport type + GPS + per-session aggregates. | -| Intraday activity (getintradayactivity) | 2 | 3 | 4 | v1.5 | Per-minute samples; volume question (50k+ rows/day per user). Defer until storage strategy is set. | -| ECG signal (Heart-Get) | 5 | 1 | 1 | v1.5 | 30s @ 500 Hz signals; needs blob storage strategy + cardiologist-disclaimer copy. Off-strategy for clinical interpretation. | -| AFib events (Heart-List + appli=54) | 4 | 2 | 2 | v1.5 | Needs `CardiacEvent` model + alerting policy. Clinical surface — design care. | -| meastype 167 Nerve Health Score | 1 | 4 | 3 | v1.5 | US-only via Total Biomarker Pack on Body Comp / Body Scan; niche. | -| meastype 168/169 Extracellular / Intracellular Water | 2 | 4 | 3 | v1.5 | Body Scan exclusive; clinical-adjacent. | -| meastype 174/175 Segmental composition (per-limb) | 2 | 3 | 1 | v1.5 | JSON sub-document; needs new column or sidecar table. | -| meastype 196 Electrodermal Activity | 1 | 3 | 3 | v1.5 | Nerve-health module; niche. | -| meastype 4 Height | 3 | 4 | 4 | v1.5 | Belongs on `User` profile, not as time-series. Wire to `User.heightCm` with last-write-wins. | -| Devices list (User-Getdevice) | 3 | 4 | 5 | v1.5 | "Connected device" badge under Settings; needs a new API route + UI surface. | +| Gap | UV | E | S | Wave | Rationale | +| -------------------------------------------------------- | --- | --- | --- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| meastype 12 Temperature (legacy) | 2 | 5 | 5 | **v1.4.25** | One line in `MEASURE_TYPE_MAP`. Enum already has `BODY_TEMPERATURE`. | +| meastype 71 Body Temperature (Thermo) | 3 | 5 | 5 | **v1.4.25** | Same. Thermo owners get fever tracking sync. | +| meastype 35 SpO2 (alt code) | 3 | 5 | 5 | **v1.4.25** | Same. Some devices report 35 instead of 54. | +| meastype 123 VO2 max | 3 | 5 | 5 | **v1.4.25** | Same. Enum already has `VO2_MAX`. | +| Subscribe appli=4 (BP webhook) | 5 | 5 | 5 | **v1.4.25** | Latency bug fix. BP currently waits an hour; should be seconds. | +| Subscribe appli=2 (temperature webhook) | 2 | 5 | 5 | **v1.4.25** | Cheap; matches the new meastype 12/71 mapping. | +| meastype 73 Skin Temperature | 2 | 4 | 3 | v1.4.26 | Needs new `SKIN_TEMPERATURE` enum (different physiology than core). | +| meastype 5/8/76 Body composition (FFM / FM / Muscle) | 4 | 4 | 3 | v1.4.26 | Three new enum values + i18n strings + chart slots. Body Comp / Body Scan owners care. | +| meastype 170 Visceral Fat | 3 | 4 | 3 | v1.4.26 | New enum slot. Body Comp / Body Scan. | +| meastype 91 Pulse Wave Velocity | 3 | 4 | 3 | v1.4.26 | New enum slot. Body Cardio / Body Scan, EU+US only. | +| meastype 155 Vascular Age | 2 | 4 | 3 | v1.4.26 | New enum slot. Composite of PWV + age — surfaces a "year" value. | +| Sleep API (getsummary) — duration + stages + HRV | 5 | 3 | 4 | v1.4.26 | New sync routine; uses existing `SLEEP_DURATION`/`HEART_RATE_VARIABILITY` enums + `SleepStage`. Pairs with W4-shipped sleep chart. | +| Activity API (getactivity) — steps + distance + calories | 4 | 3 | 5 | v1.4.26 | Existing enums; scope upgrade requires user reauth. | +| Workouts API (getworkouts) | 3 | 2 | 1 | v1.5 | Needs `Workout` model with sport type + GPS + per-session aggregates. | +| Intraday activity (getintradayactivity) | 2 | 3 | 4 | v1.5 | Per-minute samples; volume question (50k+ rows/day per user). Defer until storage strategy is set. | +| ECG signal (Heart-Get) | 5 | 1 | 1 | v1.5 | 30s @ 500 Hz signals; needs blob storage strategy + cardiologist-disclaimer copy. Off-strategy for clinical interpretation. | +| AFib events (Heart-List + appli=54) | 4 | 2 | 2 | v1.5 | Needs `CardiacEvent` model + alerting policy. Clinical surface — design care. | +| meastype 167 Nerve Health Score | 1 | 4 | 3 | v1.5 | US-only via Total Biomarker Pack on Body Comp / Body Scan; niche. | +| meastype 168/169 Extracellular / Intracellular Water | 2 | 4 | 3 | v1.5 | Body Scan exclusive; clinical-adjacent. | +| meastype 174/175 Segmental composition (per-limb) | 2 | 3 | 1 | v1.5 | JSON sub-document; needs new column or sidecar table. | +| meastype 196 Electrodermal Activity | 1 | 3 | 3 | v1.5 | Nerve-health module; niche. | +| meastype 4 Height | 3 | 4 | 4 | v1.5 | Belongs on `User` profile, not as time-series. Wire to `User.heightCm` with last-write-wins. | +| Devices list (User-Getdevice) | 3 | 4 | 5 | v1.5 | "Connected device" badge under Settings; needs a new API route + UI surface. | --- @@ -240,17 +245,18 @@ Scale: User-value (UV) 5=most users / 1=niche. Effort (E) 5=trivial / 1=multi-da Withings push (notify) covers every category in §2.6. HealthLog **today** only subscribes to `appli=1`, meaning: -| Category | What it covers | HealthLog has webhook today? | Latency on a new reading | -|---|---|---|---| -| 1 — Weight/composition | meastypes 1, 5, 6, 8, 88 | ✅ yes | ~seconds | -| 4 — BP & SpO2 | meastypes 9, 10, 11, 54 | ❌ no | up to 1 hour (poll) | -| 2 — Temperature | meastypes 12, 71, 73 | ❌ no | up to 1 hour | -| 44 — Sleep | sleep summary | ❌ no (not ingested at all) | n/a | -| 16 — Activity | steps / distance / calories | ❌ no (not ingested at all) | n/a | -| 54 — ECG | ECG records | ❌ no | n/a | -| 58 — Glucose | meastype 119 | ❌ no | n/a | +| Category | What it covers | HealthLog has webhook today? | Latency on a new reading | +| ---------------------- | --------------------------- | ---------------------------- | ------------------------ | +| 1 — Weight/composition | meastypes 1, 5, 6, 8, 88 | ✅ yes | ~seconds | +| 4 — BP & SpO2 | meastypes 9, 10, 11, 54 | ❌ no | up to 1 hour (poll) | +| 2 — Temperature | meastypes 12, 71, 73 | ❌ no | up to 1 hour | +| 44 — Sleep | sleep summary | ❌ no (not ingested at all) | n/a | +| 16 — Activity | steps / distance / calories | ❌ no (not ingested at all) | n/a | +| 54 — ECG | ECG records | ❌ no | n/a | +| 58 — Glucose | meastype 119 | ❌ no | n/a | **Cadence reasonableness for poll-only fallback:** + - Hourly poll for weight/BP/temperature is fine — those are spot readings made a few times per day. - For sleep, hourly poll is fine — there's one summary per night. - For activity, hourly poll would miss the live-step feedback loop entirely. **Activity ingest cannot rely on poll** — it requires webhook. @@ -263,16 +269,17 @@ Withings push (notify) covers every category in §2.6. HealthLog **today** only ## Section 5 — OAuth scope + subscription tier -| Scope | Endpoints | Status | -|---|---|---| -| `user.metrics` | Measure-Getmeas, Heart v2 (BP from cuffs, SpO2, ECG metadata) | ✅ already requested | -| `users.activity` | Activity, Sleep | ❌ not requested. Needs scope-string update + user reauth on next connect. | -| `user.info` | User v2 (devices, profile) | ❌ not requested. | -| `user.sleepevents` | Bed-in / bed-out / inflate events | ❌ not requested. Niche — defer to v1.5. | +| Scope | Endpoints | Status | +| ------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `user.metrics` | Measure-Getmeas, Heart v2 (BP from cuffs, SpO2, ECG metadata) | ✅ already requested | +| `users.activity` | Activity, Sleep | ❌ not requested. Needs scope-string update + user reauth on next connect. | +| `user.info` | User v2 (devices, profile) | ❌ not requested. | +| `user.sleepevents` | Bed-in / bed-out / inflate events | ❌ not requested. Niche — defer to v1.5. | **Withings+ subscription:** none of the endpoints listed above require Withings+ on the user side. The "Cardio Check-Up review" is the only Withings+-locked surface that exposes derived data, and it ships as a PDF to the user — there's no API for HealthLog to pull from. **Developer-app billing tier (partner hub):** + - Basic Biomarker Pack: weight, body fat %, BMR, BP, pulse, SpO2 — covers everything we ingest today. - Total Biomarker Pack: adds PWV, nerve health, ECG signal, segmental composition. Required for v1.4.26+ work that touches PWV/NHS/segmental. @@ -282,17 +289,18 @@ For HealthLog self-hosters, the partner hub pack is what their **own Withings de ## Section 6 — Files that need touching for v1.4.25 -| File | Change | -|---|---| -| `src/lib/withings/client.ts` | Extend `MEASURE_TYPE_MAP` with meastype 12, 35, 71, 123. | -| `src/lib/withings/sync.ts` | Call `subscribeWebhook(token, url, 2)` and `subscribeWebhook(token, url, 4)` alongside the existing `appli=1`. | -| `src/lib/withings/__tests__/client.test.ts` | **New file.** Cover the four new mappings + edge cases (unit exponent, missing fields, unknown meastype). | -| `src/lib/withings/mapping.md` | **New file.** Single source-of-truth table of every meastype → MeasurementType. Reference from `client.ts` doc comment. | -| `docs/api/openapi.yaml` | No change. Endpoint surface didn't change. | -| `messages/en.json` + `messages/de.json` | No new user-facing keys needed for the meastype additions — the values surface in existing measurement charts via their already-localised types. | -| `.planning/research/withings-api-coverage.md` | This file (delivered). | +| File | Change | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/lib/withings/client.ts` | Extend `MEASURE_TYPE_MAP` with meastype 12, 35, 71, 123. | +| `src/lib/withings/sync.ts` | Call `subscribeWebhook(token, url, 2)` and `subscribeWebhook(token, url, 4)` alongside the existing `appli=1`. | +| `src/lib/withings/__tests__/client.test.ts` | **New file.** Cover the four new mappings + edge cases (unit exponent, missing fields, unknown meastype). | +| `src/lib/withings/mapping.md` | **New file.** Single source-of-truth table of every meastype → MeasurementType. Reference from `client.ts` doc comment. | +| `docs/api/openapi.yaml` | No change. Endpoint surface didn't change. | +| `messages/en.json` + `messages/de.json` | No new user-facing keys needed for the meastype additions — the values surface in existing measurement charts via their already-localised types. | +| `.planning/research/withings-api-coverage.md` | This file (delivered). | For v1.4.26 (NOT in this wave): + - `prisma/schema.prisma` — add `FAT_MASS`, `FAT_FREE_MASS`, `MUSCLE_MASS`, `SKIN_TEMPERATURE`, `PULSE_WAVE_VELOCITY`, `VISCERAL_FAT`, `VASCULAR_AGE` to `MeasurementType` enum. - `prisma/migrations/0046_withings_body_composition_metrics/migration.sql` — additive enum values. - `src/lib/validations/measurement.ts` — extend `measurementTypeEnum`, `unitMap`, `VALUE_RANGES`. @@ -320,6 +328,7 @@ Each commit ships green tests (`pnpm test`, `pnpm typecheck`, `pnpm lint`). ## Section 8 — Deferred items + rationale ### v1.4.26 (next maintenance wave) + - **Body composition expansion (meastypes 5, 8, 76)** — `FAT_FREE_MASS`, `FAT_MASS`, `MUSCLE_MASS` enum values. Body Comp / Body Scan ships these on every step; not surfacing them feels under-built for Withings owners. Schema migration is purely additive (new enum values). - **Skin temperature (meastype 73)** — `SKIN_TEMPERATURE` enum. Distinct physiology from core body temp. - **Pulse wave velocity (meastype 91)** + **Vascular age (meastype 155)** — Body Cardio / Body Scan exclusive; defer until enum expansion lands. @@ -329,6 +338,7 @@ Each commit ships green tests (`pnpm test`, `pnpm typecheck`, `pnpm lint`). - **Webhook subscriptions appli=16 (activity) and appli=44 (sleep)** — paired with the above sync routines. ### v1.5 (paired with iOS app) + - **ECG signal (Heart-Get)** — 30 s × 500 Hz blob per capture; needs storage strategy (signed-URL object store? Postgres `bytea`?), retention policy, and the "this is not a diagnosis" disclaimer copy reviewed by Marc before shipping. - **AFib events (Heart-List + appli=54)** — needs `CardiacEvent` model and an alerting policy. Clinical-adjacent: requires careful product copy to avoid "HealthLog detected AFib" → "Withings detected AFib, here's the record" framing. - **Workouts (Getworkouts)** — needs `Workout` model: sport type, start/end, distance, calories, GPS polyline. iOS app context first because workouts will round-trip through Apple Health and ordering matters. @@ -339,6 +349,7 @@ Each commit ships green tests (`pnpm test`, `pnpm typecheck`, `pnpm lint`). - **Nerve health (167) / Extracellular & Intracellular Water (168/169) / Electrodermal Activity (196)** — Body Scan-exclusive niche. ### Skip with rationale + - **Withings+ derived metrics (Health Improvement Score, Vitality Indicator, Cardio Check-Up cardiologist review)** — not exposed by the public API. Computed inside Withings' app, not reachable by any partner. Cross-reference `.planning/research/withings-plus-comparison.md` §6 — HealthLog's own AI surfaces (Health Score, Coach, briefing) are the answer here. - **Sleep events appli=50/51/52 (bed-in / bed-out / inflate)** — niche; only useful with a Withings Sleep Analyzer mat. Defer indefinitely; we'd surface them only if a user explicitly asks. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 089b59e3a..11830f7aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -210,7 +210,8 @@ components: - error type: object DeviceRegisterRequest: - description: Native device registration. Re-registering an APNs token belonging to another user returns 409 + description: + Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard). properties: apnsEnvironment: @@ -521,7 +522,8 @@ components: type: object InsightsComprehensiveResponse: additionalProperties: false - description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface + description: + AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding. properties: citations: @@ -691,6 +693,13 @@ components: - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT type: string unit: type: string @@ -705,7 +714,8 @@ components: - source type: object MeasurementsDeleteByExternalIdsRequest: - description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped + description: + iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard). properties: externalIds: @@ -1096,7 +1106,8 @@ openapi: 3.1.0 paths: /api/auth/login: post: - description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA + description: + "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token." requestBody: content: @@ -1144,7 +1155,8 @@ paths: - Auth /api/auth/me/coach-prefs: get: - description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy + description: + Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults. responses: "200": @@ -1160,7 +1172,8 @@ paths: tags: - Auth put: - description: Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the + description: + Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults. requestBody: content: @@ -1225,7 +1238,8 @@ paths: - Auth /api/auth/me/source-priority: get: - description: Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to + description: + Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults. responses: "200": @@ -1241,7 +1255,8 @@ paths: tags: - Auth put: - description: Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as + description: + Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site. requestBody: content: @@ -1426,7 +1441,8 @@ paths: - Auth /api/auth/refresh: post: - description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device + description: + Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user. requestBody: @@ -1450,7 +1466,8 @@ paths: - Auth /api/devices: post: - description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns + description: + Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409. requestBody: content: @@ -1479,7 +1496,8 @@ paths: - Devices /api/insights/chat/messages/{id}/feedback: post: - description: Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback + description: + Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity). requestBody: @@ -1515,7 +1533,8 @@ paths: - Insights /api/insights/comprehensive: get: - description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard + description: + Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. responses: "200": @@ -1556,6 +1575,13 @@ paths: - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT type: string - in: query name: from @@ -1663,6 +1689,13 @@ paths: - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT type: string value: type: number @@ -1687,7 +1720,8 @@ paths: - Measurements /api/measurements/batch: post: - description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). + description: + Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. requestBody: content: @@ -1710,7 +1744,8 @@ paths: - Measurements /api/measurements/by-external-ids: delete: - description: Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are + description: + Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call. requestBody: content: diff --git a/docs/audit/v1423-summary.md b/docs/audit/v1423-summary.md index 8f0598693..8081f99cb 100644 --- a/docs/audit/v1423-summary.md +++ b/docs/audit/v1423-summary.md @@ -6,14 +6,14 @@ v1.4.23 is the foundation release v1.4.x hasn't shipped before: the user-visible ## Live state -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------- | -| URL | `https://healthlog.bombeck.io` | -| `/api/version` | `1.4.23` | -| Image digest | `sha256:b20c25a49b1835aa66c03a4670c27a3ba0ea9414dab3ee4a45f5cd35d4f353d1` | -| Version transition | 2026-05-11T06:40:49Z (host-side retag fallback) | -| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.23 | -| Branch model | `develop` → `main` release-merge model, fourth tag through it (v1.4.20 onwards) | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------- | +| URL | `https://healthlog.bombeck.io` | +| `/api/version` | `1.4.23` | +| Image digest | `sha256:b20c25a49b1835aa66c03a4670c27a3ba0ea9414dab3ee4a45f5cd35d4f353d1` | +| Version transition | 2026-05-11T06:40:49Z (host-side retag fallback) | +| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.23 | +| Branch model | `develop` → `main` release-merge model, fourth tag through it (v1.4.20 onwards) | ## Smoke (no session) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index f7e357a5e..c58e0b819 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -321,7 +321,8 @@ async function computeSleepStageBreakdown( const dayKey = userDayKey(row.measuredAt, userTz); dayKeys.add(dayKey); const nightStages = perNightMap.get(dayKey) ?? {}; - nightStages[row.sleepStage] = (nightStages[row.sleepStage] ?? 0) + row.value; + nightStages[row.sleepStage] = + (nightStages[row.sleepStage] ?? 0) + row.value; perNightMap.set(dayKey, nightStages); } diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index aa822ea71..31e171556 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -63,7 +63,9 @@ describe("GET /api/auth/me/doctor-report-prefs", () => { new Request("http://localhost/api/auth/me/doctor-report-prefs"), ); expect(res.status).toBe(200); - const env = (await res.json()) as { data: typeof DEFAULT_DOCTOR_REPORT_PREFS }; + const env = (await res.json()) as { + data: typeof DEFAULT_DOCTOR_REPORT_PREFS; + }; expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS); expect(env.data.mood).toBe(false); // privacy default per Marc }); diff --git a/src/app/api/dashboard/glp1/route.ts b/src/app/api/dashboard/glp1/route.ts index 4244624fe..4e5b2cdcd 100644 --- a/src/app/api/dashboard/glp1/route.ts +++ b/src/app/api/dashboard/glp1/route.ts @@ -128,8 +128,7 @@ export const GET = apiHandler(async () => { const injectionDatesByMed = new Map>(); for (const ev of intakeRows) { if (!ev.takenAt) continue; - const set = - injectionDatesByMed.get(ev.medicationId) ?? new Set(); + const set = injectionDatesByMed.get(ev.medicationId) ?? new Set(); set.add(isoDate(ev.takenAt)); injectionDatesByMed.set(ev.medicationId, set); } diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts index 2426a84b1..1aead558d 100644 --- a/src/components/admin/__tests__/login-overview-csv.test.ts +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -227,7 +227,7 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => { "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,", + '2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,', ); }); diff --git a/src/components/admin/__tests__/login-overview-provider.test.tsx b/src/components/admin/__tests__/login-overview-provider.test.tsx index 59d365451..74994143f 100644 --- a/src/components/admin/__tests__/login-overview-provider.test.tsx +++ b/src/components/admin/__tests__/login-overview-provider.test.tsx @@ -1,9 +1,6 @@ import { describe, it, expect } from "vitest"; import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react"; -import { - iconForAuthProvider, - providerForAction, -} from "../_shared"; +import { iconForAuthProvider, providerForAction } from "../_shared"; /** * v1.4.25 W8b — Provider column for the admin Login-Übersicht. diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts index 2d4605610..6babb1ac2 100644 --- a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -32,14 +32,11 @@ describe("resolveVerticalMarkerPositions", () => { it("returns empty when chartData is undefined or empty", () => { expect( - resolveVerticalMarkerPositions( - [{ date: "2026-04-15" }], - undefined, - ), + resolveVerticalMarkerPositions([{ date: "2026-04-15" }], undefined), + ).toEqual([]); + expect( + resolveVerticalMarkerPositions([{ date: "2026-04-15" }], []), ).toEqual([]); - expect(resolveVerticalMarkerPositions([{ date: "2026-04-15" }], [])).toEqual( - [], - ); }); it("pins a single in-window marker to its exact day-key", () => { @@ -55,11 +52,7 @@ describe("resolveVerticalMarkerPositions", () => { 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" }, - ], + [{ date: "2026-04-15" }, { date: "2026-04-22" }, { date: "2026-04-19" }], dailyChartData, ); expect(positions.map((p) => p.pointIndex)).toEqual([0, 7, 4]); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 1f967dca7..5da206e54 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -337,9 +337,7 @@ export interface ResolvedVerticalMarker { } export function resolveVerticalMarkerPositions( - markers: - | Array<{ date: string; label?: string; color?: string }> - | undefined, + markers: Array<{ date: string; label?: string; color?: string }> | undefined, chartData: Array<{ date: string }> | undefined, ): ResolvedVerticalMarker[] { if (!markers || !chartData || chartData.length === 0) return []; diff --git a/src/components/dashboard/__tests__/glp1-tile.test.tsx b/src/components/dashboard/__tests__/glp1-tile.test.tsx index 1f78cb0b9..0123f6da2 100644 --- a/src/components/dashboard/__tests__/glp1-tile.test.tsx +++ b/src/components/dashboard/__tests__/glp1-tile.test.tsx @@ -274,7 +274,12 @@ describe("", () => { name: "Mounjaro", genericName: "tirzepatide", medicationId: "med-1", - currentDose: { value: 7.5, unit: "mg", since: "2026-04-01", weeksOnDose: 6 }, + 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 }, diff --git a/src/components/dashboard/glp1-tile.tsx b/src/components/dashboard/glp1-tile.tsx index 3767a8c41..5c2597fdc 100644 --- a/src/components/dashboard/glp1-tile.tsx +++ b/src/components/dashboard/glp1-tile.tsx @@ -137,10 +137,7 @@ interface DeltaDisplay { * - 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 { +function useDeltaDisplay(): (deltaKg: number, suffix: string) => DeltaDisplay { const fmt = useFormatters(); return useMemo(() => { return (deltaKg: number, suffix: string) => { @@ -279,7 +276,11 @@ export function Glp1Tile() { > {dateWithWeekday(med.nextInjection.date)} - ({t("dashboard.glp1.inDays", { count: med.nextInjection.daysAway })}) + ( + {t("dashboard.glp1.inDays", { + count: med.nextInjection.daysAway, + })} + ) diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 30ceea2ef..75416295d 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -126,7 +126,10 @@ function defaultStartIso(): string { return formatLocalDate(d); } -function rangeIsoFromLocal(startDate: string, endDate: string): { +function rangeIsoFromLocal( + startDate: string, + endDate: string, +): { startIso: string; endIso: string; } { @@ -341,8 +344,7 @@ export function DoctorReportDialog({ // 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; + sections[key] = (availability?.[key] ?? false) && prefs[key] === true; } // Fire-and-forget persistence so the user doesn't wait on it. @@ -530,7 +532,7 @@ function SectionToggles({ className="border-border bg-muted/30 space-y-2 rounded-lg border p-3" data-testid="doctor-report-sections-loading" > -

+

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

@@ -550,7 +552,7 @@ function SectionToggles({ className="border-border bg-muted/30 rounded-lg border p-3" data-testid="doctor-report-sections-empty" > -

+

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

@@ -566,7 +568,7 @@ function SectionToggles({ data-testid="doctor-report-sections" >

-

+

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

{availabilityLoading && ( @@ -583,7 +585,7 @@ function SectionToggles({ className="hover:bg-muted/50 -mx-2 flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors" > - + {t(SECTION_LABEL_KEYS[key])} {key === "mood" && ( diff --git a/src/components/insights/insights-card.tsx b/src/components/insights/insights-card.tsx index c481277f5..c593bb755 100644 --- a/src/components/insights/insights-card.tsx +++ b/src/components/insights/insights-card.tsx @@ -102,9 +102,7 @@ export function InsightsCardPreview({ key={id ?? index} className={`bg-card/40 flex items-start justify-between gap-2 rounded-md border-l-2 px-3 py-2 ${borderClass}`} > -

- {stripChartTokens(text)} -

+

{stripChartTokens(text)}

{typeof confidence === "number" && ( diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 8f0b67efe..471b0c289 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -267,7 +267,8 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { { - if (!active || !payload || payload.length === 0) return null; + if (!active || !payload || payload.length === 0) + return null; const totalNight = payload.reduce( (sum, entry) => sum + diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 086d422b1..b4b44a487 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -87,7 +87,9 @@ const defaultMed = { function makeClient(): QueryClient { return new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: Infinity } }, + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, }); } @@ -296,7 +298,10 @@ describe(" — GLP-1 variant rendering", () => { seedGlp1Details(client, med7p5.id, { recentIntakes: [ { takenAt: "2026-05-08T08:00:00Z", injectionSite: "ABDOMEN_LEFT" }, - { takenAt: "2026-05-01T08:00:00Z", injectionSite: "ABDOMEN_UPPER_LEFT" }, + { + takenAt: "2026-05-01T08:00:00Z", + injectionSite: "ABDOMEN_UPPER_LEFT", + }, ], }); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index bdf2e5de9..9da0b949c 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -373,19 +373,13 @@ function WithingsCard({ className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors" > - {t( - "settings.integrations.withings.reconnect.banner.title", - )} + {t("settings.integrations.withings.reconnect.banner.title")} - {t( - "settings.integrations.withings.reconnect.banner.body", - )} + {t("settings.integrations.withings.reconnect.banner.body")} - {t( - "settings.integrations.withings.reconnect.banner.action", - )} + {t("settings.integrations.withings.reconnect.banner.action")} {" →"} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index bba3091ef..8a000232d 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -186,8 +186,7 @@ export function SourcesSection() { ) : (
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => { - const list = - priority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; + const list = priority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; return (
{ + const seedRange = ( + m: ThresholdMetric | null, + fallback: CurrentRange | null, + ) => { if (m && thresholdsData?.overrides?.[m]) { return thresholdsData.overrides[m]!; } @@ -158,10 +161,7 @@ function TargetEditSheetBody({ return { min: 0, max: 100 }; }; - const primary = seedRange( - isBp ? "BLOOD_PRESSURE_SYS" : metric, - initialRange, - ); + const primary = seedRange(isBp ? "BLOOD_PRESSURE_SYS" : metric, initialRange); const secondary = isBp ? seedRange("BLOOD_PRESSURE_DIA", initialDiastolicRange ?? null) : null; @@ -190,7 +190,9 @@ function TargetEditSheetBody({ const dialogContentId = useId(); const updateMutation = useMutation({ - mutationFn: async (payload: Partial>) => { + mutationFn: async ( + payload: Partial>, + ) => { const res = await fetch("/api/user/thresholds", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -210,7 +212,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - toast.error(err instanceof Error ? err.message : t("targets.edit.saveError")), + toast.error( + err instanceof Error ? err.message : t("targets.edit.saveError"), + ), }); const resetMutation = useMutation({ @@ -291,7 +295,7 @@ function TargetEditSheetBody({ const hasOverride = isBp ? Boolean( thresholdsData?.overrides?.BLOOD_PRESSURE_SYS || - thresholdsData?.overrides?.BLOOD_PRESSURE_DIA, + thresholdsData?.overrides?.BLOOD_PRESSURE_DIA, ) : metric ? Boolean(thresholdsData?.overrides?.[metric]) @@ -314,146 +318,146 @@ function TargetEditSheetBody({ }} aria-describedby={dialogContentId} > - - - {t("targets.edit.title", { metric: targetLabel })} - - - {isDerivedMetric - ? t("targets.edit.derivedHint") - : t("targets.edit.description", { unit })} - - + + + {t("targets.edit.title", { metric: targetLabel })} + + + {isDerivedMetric + ? t("targets.edit.derivedHint") + : t("targets.edit.description", { unit })} + + - {!isDerivedMetric && primaryBounds && ( -
+ {!isDerivedMetric && primaryBounds && ( +
+
+
+ + setMinStr(e.target.value)} + disabled={busy} + data-slot="target-edit-min-input" + /> +
+
+ + setMaxStr(e.target.value)} + disabled={busy} + data-slot="target-edit-max-input" + /> +
+
+ + {isBp && secondaryBounds && (
-
-
- - {isBp && secondaryBounds && ( -
-
- - setDiaMinStr(e.target.value)} - disabled={busy} - data-slot="target-edit-dia-min-input" - /> -
-
- - setDiaMaxStr(e.target.value)} - disabled={busy} - data-slot="target-edit-dia-max-input" - /> -
-
- )} - -

- {t("targets.edit.boundsHint", { - min: String(primaryBounds.min), - max: String(primaryBounds.max), - unit: primaryBounds.unit, - })} -

-
- )} - - - {hasOverride ? ( - - ) : ( - - + +

+ {t("targets.edit.boundsHint", { + min: String(primaryBounds.min), + max: String(primaryBounds.max), + unit: primaryBounds.unit, + })} +

+
+ )} + + + {hasOverride ? ( + + ) : ( + + ); } diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 324d55a47..6244ddec9 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -14,7 +14,10 @@ const DE_PATH = join(MESSAGES_DIR, "de.json"); function discoverLocales(): Array<{ locale: string; path: string }> { return readdirSync(MESSAGES_DIR) .filter((f) => f.endsWith(".json")) - .map((f) => ({ locale: f.replace(/\.json$/, ""), path: join(MESSAGES_DIR, f) })); + .map((f) => ({ + locale: f.replace(/\.json$/, ""), + path: join(MESSAGES_DIR, f), + })); } const ALL_LOCALES = discoverLocales(); @@ -210,9 +213,7 @@ describe("i18n locale file integrity", () => { otherArr.forEach((k) => otherKeys.add(k)); const onlyInEn = [...enKeys].filter((k) => !otherKeys.has(k)).sort(); - const onlyInOther = [...otherKeys] - .filter((k) => !enKeys.has(k)) - .sort(); + const onlyInOther = [...otherKeys].filter((k) => !enKeys.has(k)).sort(); expect( { onlyInEn, onlyInOther }, @@ -315,22 +316,23 @@ describe("i18n locale file integrity", () => { it.each(ALL_LOCALES)( "$locale locale has no TODO/FIXME placeholders in values", ({ path }) => { - const data = JSON.parse(readFileSync(path, "utf8")) as Record< - string, - unknown - >; - const flat: [string, string][] = []; - flattenValues(data, "", flat); - const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/; - const todos = flat - .filter(([, v]) => todoRe.test(v)) - .map(([k, v]) => `${k} = ${JSON.stringify(v)}`); - expect( - todos, - `Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` + - todos.map((s) => ` ${s}`).join("\n"), - ).toEqual([]); - }); + const data = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + const flat: [string, string][] = []; + flattenValues(data, "", flat); + const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/; + const todos = flat + .filter(([, v]) => todoRe.test(v)) + .map(([k, v]) => `${k} = ${JSON.stringify(v)}`); + expect( + todos, + `Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` + + todos.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); + }, + ); /** * v1.4.22 A6 — DE locale must not leak English nouns for the diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index 512568e44..4d2dcbb58 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -23,8 +23,16 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { // v1.4.25 reality: only WITHINGS rows exist; the picker is a // pass-through and the daily total matches the pre-W5e behaviour. const rows = [ - { measuredAt: new Date("2026-05-12T08:00:00Z"), source: "WITHINGS" as const, value: 4000 }, - { measuredAt: new Date("2026-05-12T20:00:00Z"), source: "WITHINGS" as const, value: 2000 }, + { + measuredAt: new Date("2026-05-12T08:00:00Z"), + source: "WITHINGS" as const, + value: 4000, + }, + { + measuredAt: new Date("2026-05-12T20:00:00Z"), + source: "WITHINGS" as const, + value: 2000, + }, ]; const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey); expect(out.canonicalRows).toHaveLength(2); @@ -37,9 +45,21 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { const rows = [ // Same day, both sources reported — naïvely summing would // double-count (8500 instead of 5500 or 5000). - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "WITHINGS" as const, value: 3000 }, - { measuredAt: new Date("2026-05-12T18:00:00Z"), source: "WITHINGS" as const, value: 2000 }, - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "APPLE_HEALTH" as const, value: 5500 }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "WITHINGS" as const, + value: 3000, + }, + { + measuredAt: new Date("2026-05-12T18:00:00Z"), + source: "WITHINGS" as const, + value: 2000, + }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "APPLE_HEALTH" as const, + value: 5500, + }, ]; const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey); expect(out.canonicalRows).toHaveLength(1); @@ -50,14 +70,31 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { it("respects a user override (MANUAL > WITHINGS > APPLE_HEALTH for weight)", () => { const rows = [ - { measuredAt: new Date("2026-05-12T07:00:00Z"), source: "WITHINGS" as const, value: 82.4 }, - { measuredAt: new Date("2026-05-12T07:30:00Z"), source: "APPLE_HEALTH" as const, value: 82.3 }, - { measuredAt: new Date("2026-05-12T08:00:00Z"), source: "MANUAL" as const, value: 82.0 }, + { + measuredAt: new Date("2026-05-12T07:00:00Z"), + source: "WITHINGS" as const, + value: 82.4, + }, + { + measuredAt: new Date("2026-05-12T07:30:00Z"), + source: "APPLE_HEALTH" as const, + value: 82.3, + }, + { + measuredAt: new Date("2026-05-12T08:00:00Z"), + source: "MANUAL" as const, + value: 82.0, + }, ]; const userPriority = { weight: ["MANUAL", "WITHINGS", "APPLE_HEALTH"], }; - const out = pickCanonicalSourceRows(rows, "weight", userPriority, isoDayKey); + const out = pickCanonicalSourceRows( + rows, + "weight", + userPriority, + isoDayKey, + ); expect(out.canonicalRows).toHaveLength(1); expect(out.canonicalRows[0].source).toBe("MANUAL"); expect(out.canonicalRows[0].value).toBe(82.0); @@ -66,10 +103,22 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { it("picks per-day independently — different sources on different days", () => { const rows = [ // 2026-05-12 — only Withings reported. - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "WITHINGS" as const, value: 5500 }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "WITHINGS" as const, + value: 5500, + }, // 2026-05-13 — both reported. - { measuredAt: new Date("2026-05-13T09:00:00Z"), source: "WITHINGS" as const, value: 3000 }, - { measuredAt: new Date("2026-05-13T09:00:00Z"), source: "APPLE_HEALTH" as const, value: 4800 }, + { + measuredAt: new Date("2026-05-13T09:00:00Z"), + source: "WITHINGS" as const, + value: 3000, + }, + { + measuredAt: new Date("2026-05-13T09:00:00Z"), + source: "APPLE_HEALTH" as const, + value: 4800, + }, ]; const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey); expect(out.canonicalRows).toHaveLength(2); @@ -84,7 +133,11 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { // fallback, every IMPORT row would silently drop from the // aggregation and the daily total would read zero. const rows = [ - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "IMPORT" as const, value: 6000 }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "IMPORT" as const, + value: 6000, + }, ]; const out = pickCanonicalSourceRows(rows, "steps", null, isoDayKey); expect(out.canonicalRows).toHaveLength(1); @@ -93,8 +146,16 @@ describe("pickCanonicalSourceRows — cumulative-metric picker", () => { it("handles a malformed priority Json blob by falling back to defaults", () => { const rows = [ - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "WITHINGS" as const, value: 3000 }, - { measuredAt: new Date("2026-05-12T09:00:00Z"), source: "APPLE_HEALTH" as const, value: 5500 }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "WITHINGS" as const, + value: 3000, + }, + { + measuredAt: new Date("2026-05-12T09:00:00Z"), + source: "APPLE_HEALTH" as const, + value: 5500, + }, ]; // Garbage payload — `parseSourcePriority` returns defaults. const out = pickCanonicalSourceRows(rows, "steps", "not-json", isoDayKey); diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index 2a7ee662b..59bb2704c 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -119,9 +119,9 @@ describe("stripChartTokens — v1.4.25 W5b capitalised + orphan-enum leaks", () }); it("strips orphan enum mentions — MEDICATION_COMPLIANCE_PCT", () => { - expect( - stripChartTokens("MEDICATION_COMPLIANCE_PCT held above 90."), - ).toBe("held above 90."); + expect(stripChartTokens("MEDICATION_COMPLIANCE_PCT held above 90.")).toBe( + "held above 90.", + ); }); it("strips v1.4.23 Apple Health additions — HEART_RATE_VARIABILITY", () => { @@ -144,13 +144,13 @@ describe("stripChartTokens — v1.4.25 W5b capitalised + orphan-enum leaks", () it("strips v1.4.23 Apple Health additions — FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE, SLEEP_DURATION", () => { expect(stripChartTokens("FLIGHTS_CLIMBED at 5/day.")).toBe("at 5/day."); - expect( - stripChartTokens("WALKING_RUNNING_DISTANCE averaged 4 km."), - ).toBe("averaged 4 km."); + expect(stripChartTokens("WALKING_RUNNING_DISTANCE averaged 4 km.")).toBe( + "averaged 4 km.", + ); expect(stripChartTokens("VO2_MAX has crept up.")).toBe("has crept up."); - expect( - stripChartTokens("BODY_TEMPERATURE sat at 36.8 most days."), - ).toBe("sat at 36.8 most days."); + expect(stripChartTokens("BODY_TEMPERATURE sat at 36.8 most days.")).toBe( + "sat at 36.8 most days.", + ); expect(stripChartTokens("SLEEP_DURATION averaged 7.4h.")).toBe( "averaged 7.4h.", ); @@ -172,9 +172,9 @@ describe("stripChartTokens — v1.4.25 W5b capitalised + orphan-enum leaks", () // touched — it's a perfectly legitimate English word inside copy // like "Each metric in the snapshot ...". Marc's directive // explicitly targets the leak patterns, not legitimate prose. - expect( - stripChartTokens("Each metric tells part of the story."), - ).toBe("Each metric tells part of the story."); + expect(stripChartTokens("Each metric tells part of the story.")).toBe( + "Each metric tells part of the story.", + ); }); it("does not strip the canonical Metrik token in German prose", () => { diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index e5bfcc1f1..831f18996 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -122,7 +122,9 @@ describe("DEFAULT_SOURCE_PRIORITY", () => { }); it("validates against the Zod schema (no drift between defaults and validator)", () => { - expect(() => sourcePrioritySchema.parse(DEFAULT_SOURCE_PRIORITY)).not.toThrow(); + expect(() => + sourcePrioritySchema.parse(DEFAULT_SOURCE_PRIORITY), + ).not.toThrow(); }); }); diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 55d57cf97..6a1d67978 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -76,7 +76,8 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [ "vo2Max", ] as const; -export type SourcePriorityMetricKey = (typeof SOURCE_PRIORITY_METRIC_KEYS)[number]; +export type SourcePriorityMetricKey = + (typeof SOURCE_PRIORITY_METRIC_KEYS)[number]; /** * Marc-directive 2026-05-14 defaults: diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index 905f238c4..a200f52d3 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -227,7 +227,9 @@ describe("fetchMeasurements — temperature (meastype 12)", () => { it("rounds to two decimals (Withings sometimes ships fractional exponents)", async () => { // 36.825 → stored as 36.83 once `parseFloat(value.toFixed(2))` runs. - installFetchMock(fakeGetmeasPayload([{ type: 12, value: 36825, unit: -3 }])); + installFetchMock( + fakeGetmeasPayload([{ type: 12, value: 36825, unit: -3 }]), + ); const out = await fetchMeasurements("token"); expect(out[0].value).toBe(36.83); }); @@ -253,7 +255,9 @@ describe("fetchMeasurements — body composition expansion", () => { it("decodes a Body+ muscle-mass reading into MUSCLE_MASS kg", async () => { // 58.7 kg of muscle. value=58700, unit=-3. - installFetchMock(fakeGetmeasPayload([{ type: 76, value: 58700, unit: -3 }])); + installFetchMock( + fakeGetmeasPayload([{ type: 76, value: 58700, unit: -3 }]), + ); const out = await fetchMeasurements("token"); expect(out).toHaveLength(1); expect(out[0]).toMatchObject({ type: "MUSCLE_MASS", value: 58.7 }); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index 77af03ce6..82cfde9d0 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -164,7 +164,10 @@ export async function refreshAccessToken( // Withings measure type mapping. The single source of truth is // `src/lib/withings/mapping.md` — keep both in sync when adding entries. // https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas -export const MEASURE_TYPE_MAP: Record = { +export const MEASURE_TYPE_MAP: Record< + number, + { type: string; factor?: number } +> = { 1: { type: "WEIGHT" }, // Weight (kg) 9: { type: "BLOOD_PRESSURE_DIA" }, // Diastolic BP 10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP diff --git a/src/lib/withings/mapping.md b/src/lib/withings/mapping.md index 478df565c..4a4fcf595 100644 --- a/src/lib/withings/mapping.md +++ b/src/lib/withings/mapping.md @@ -11,27 +11,27 @@ Audit + scoring of the full surface lives in ## Ingested today (v1.4.25) -| meastype | Withings name | DB enum | DB unit | Source devices | -|---|---|---|---|---| -| 1 | Weight | `WEIGHT` | kg | every Body / Body+ / Body Cardio / Body Comp / Body Scan | -| 5 | Fat Free Mass | `FAT_FREE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | -| 6 | Fat Ratio | `BODY_FAT` | % | Body+ family | -| 8 | Fat Mass Weight | `FAT_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | -| 9 | Diastolic BP | `BLOOD_PRESSURE_DIA` | mmHg | BPM Connect / Core / Vision | -| 10 | Systolic BP | `BLOOD_PRESSURE_SYS` | mmHg | BPM Connect / Core / Vision | -| 11 | Heart Pulse | `PULSE` | bpm | BPM cuffs + scales (standing HR) | -| 12 | Temperature (legacy Thermo) | `BODY_TEMPERATURE` | celsius | first-gen Thermo (WBT01) | -| 35 | SpO2 (alt code) | `OXYGEN_SATURATION` | % | older firmware reports 35 instead of 54 | -| 54 | SpO2 | `OXYGEN_SATURATION` | % | ScanWatch / BPM Vision | -| 71 | Body Temperature (core) | `BODY_TEMPERATURE` | celsius | current-gen Thermo | -| 73 | Skin Temperature | `SKIN_TEMPERATURE` | celsius | ScanWatch dermal sensor. Distinct from `BODY_TEMPERATURE` — surface temps run ~32 °C, core ~37 °C; sharing the bucket would corrupt analytics. | -| 76 | Muscle Mass | `MUSCLE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | -| 77 | Hydration / Water Mass | `TOTAL_BODY_WATER` | kg | Body Comp / Body Scan | -| 88 | Bone Mass | `BONE_MASS` | kg | Body Comp / Body Scan | -| 91 | Pulse Wave Velocity | `PULSE_WAVE_VELOCITY` | m/s | Body Cardio / Body Scan exclusive | -| 123 | VO2 max | `VO2_MAX` | mL/(kg·min) | ScanWatch family | -| 155 | Vascular Age | `VASCULAR_AGE` | years | Body Scan; composite of PWV + chronological age | -| 170 | Visceral Fat | `VISCERAL_FAT` | rating | Body Comp / Body Scan; Withings' 1–12 scale (not a percent) | +| meastype | Withings name | DB enum | DB unit | Source devices | +| -------- | --------------------------- | --------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Weight | `WEIGHT` | kg | every Body / Body+ / Body Cardio / Body Comp / Body Scan | +| 5 | Fat Free Mass | `FAT_FREE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | +| 6 | Fat Ratio | `BODY_FAT` | % | Body+ family | +| 8 | Fat Mass Weight | `FAT_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | +| 9 | Diastolic BP | `BLOOD_PRESSURE_DIA` | mmHg | BPM Connect / Core / Vision | +| 10 | Systolic BP | `BLOOD_PRESSURE_SYS` | mmHg | BPM Connect / Core / Vision | +| 11 | Heart Pulse | `PULSE` | bpm | BPM cuffs + scales (standing HR) | +| 12 | Temperature (legacy Thermo) | `BODY_TEMPERATURE` | celsius | first-gen Thermo (WBT01) | +| 35 | SpO2 (alt code) | `OXYGEN_SATURATION` | % | older firmware reports 35 instead of 54 | +| 54 | SpO2 | `OXYGEN_SATURATION` | % | ScanWatch / BPM Vision | +| 71 | Body Temperature (core) | `BODY_TEMPERATURE` | celsius | current-gen Thermo | +| 73 | Skin Temperature | `SKIN_TEMPERATURE` | celsius | ScanWatch dermal sensor. Distinct from `BODY_TEMPERATURE` — surface temps run ~32 °C, core ~37 °C; sharing the bucket would corrupt analytics. | +| 76 | Muscle Mass | `MUSCLE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | +| 77 | Hydration / Water Mass | `TOTAL_BODY_WATER` | kg | Body Comp / Body Scan | +| 88 | Bone Mass | `BONE_MASS` | kg | Body Comp / Body Scan | +| 91 | Pulse Wave Velocity | `PULSE_WAVE_VELOCITY` | m/s | Body Cardio / Body Scan exclusive | +| 123 | VO2 max | `VO2_MAX` | mL/(kg·min) | ScanWatch family | +| 155 | Vascular Age | `VASCULAR_AGE` | years | Body Scan; composite of PWV + chronological age | +| 170 | Visceral Fat | `VISCERAL_FAT` | rating | Body Comp / Body Scan; Withings' 1–12 scale (not a percent) | ### Unit handling diff --git a/tests/integration/dashboard-widgets-save.test.ts b/tests/integration/dashboard-widgets-save.test.ts index b39579dde..e25d45aa2 100644 --- a/tests/integration/dashboard-widgets-save.test.ts +++ b/tests/integration/dashboard-widgets-save.test.ts @@ -83,9 +83,11 @@ async function seedUser(): Promise<{ userId: string }> { return { userId: user.id }; } -function buildClientPayload(overrides: Partial<{ - chartOverlayPrefs: Record; -}>) { +function buildClientPayload( + overrides: Partial<{ + chartOverlayPrefs: Record; + }>, +) { return { version: 1, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({