chore: resolve parallel-agent merge conflicts + sweep up loose edits

7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.

This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 14:19:08 +02:00
1 parent 7c720e8e9f
commit 98f7d11861
39 files changed
+733 -595

No files matched your search

+8 -8
View File
@@ -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`.
+5 -1
View File
@@ -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<Response>)()` →
`(GET as (r: Request) => Promise<Response>)(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
@@ -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 `<span>` rather than `<Badge variant=…>` 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
+16 -15
View File
@@ -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
@@ -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}`
+23 -21
View File
@@ -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)
+16 -16
View File
@@ -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
@@ -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<DoctorReportPrefs>`. 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.
+41 -39
View File
@@ -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
@@ -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
+11 -11
View File
@@ -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 |
---
+159 -148
View File
@@ -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.
+49 -14
View File
@@ -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:
+8 -8
View File
@@ -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)
+2 -1
View File
@@ -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);
}
@@ -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
});
+1 -2
View File
@@ -128,8 +128,7 @@ export const GET = apiHandler(async () => {
const injectionDatesByMed = new Map<string, Set<string>>();
for (const ev of intakeRows) {
if (!ev.takenAt) continue;
const set =
injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
const set = injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
set.add(isoDate(ev.takenAt));
injectionDatesByMed.set(ev.medicationId, set);
}
@@ -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,',
);
});
@@ -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.
@@ -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]);
+1 -3
View File
@@ -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 [];
@@ -274,7 +274,12 @@ describe("<Glp1Tile>", () => {
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 },
+6 -5
View File
@@ -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)}
<span className="text-muted-foreground ml-1">
({t("dashboard.glp1.inDays", { count: med.nextInjection.daysAway })})
(
{t("dashboard.glp1.inDays", {
count: med.nextInjection.daysAway,
})}
)
</span>
</dd>
</div>
@@ -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"
>
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
@@ -550,7 +552,7 @@ function SectionToggles({
className="border-border bg-muted/30 rounded-lg border p-3"
data-testid="doctor-report-sections-empty"
>
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<p className="text-muted-foreground mt-1.5 text-xs leading-relaxed">
@@ -566,7 +568,7 @@ function SectionToggles({
data-testid="doctor-report-sections"
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
{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"
>
<span className="flex flex-col">
<span className="text-sm font-medium leading-tight">
<span className="text-sm leading-tight font-medium">
{t(SECTION_LABEL_KEYS[key])}
</span>
{key === "mood" && (
+1 -3
View File
@@ -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}`}
>
<p className="text-sm leading-snug">
{stripChartTokens(text)}
</p>
<p className="text-sm leading-snug">{stripChartTokens(text)}</p>
{typeof confidence === "number" && (
<span className="shrink-0">
<ConfidenceMeter value={confidence} variant="ring" />
@@ -267,7 +267,8 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0) return null;
if (!active || !payload || payload.length === 0)
return null;
const totalNight = payload.reduce(
(sum, entry) =>
sum +
@@ -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("<Glp1MedicationCard> — 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",
},
],
});
@@ -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"
>
<span className="font-medium">
{t(
"settings.integrations.withings.reconnect.banner.title",
)}
{t("settings.integrations.withings.reconnect.banner.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t(
"settings.integrations.withings.reconnect.banner.body",
)}
{t("settings.integrations.withings.reconnect.banner.body")}
</span>
<span className="text-primary mt-1 inline-block text-xs font-medium">
{t(
"settings.integrations.withings.reconnect.banner.action",
)}
{t("settings.integrations.withings.reconnect.banner.action")}
{" →"}
</span>
</a>
+1 -2
View File
@@ -186,8 +186,7 @@ export function SourcesSection() {
) : (
<div className="space-y-3">
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
const list =
priority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric];
const list = priority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric];
return (
<div
key={metric}
+133 -129
View File
@@ -146,7 +146,10 @@ function TargetEditSheetBody({
// Seed input strings from override → initialRange → default bound.
// We use strings so the user can type a partial number (e.g. "12") without
// the input snapping back to a parsed value.
const seedRange = (m: ThresholdMetric | null, fallback: CurrentRange | null) => {
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<Record<ThresholdMetric, CurrentRange>>) => {
mutationFn: async (
payload: Partial<Record<ThresholdMetric, CurrentRange>>,
) => {
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}
>
<DialogHeader>
<DialogTitle>
{t("targets.edit.title", { metric: targetLabel })}
</DialogTitle>
<DialogDescription id={dialogContentId}>
{isDerivedMetric
? t("targets.edit.derivedHint")
: t("targets.edit.description", { unit })}
</DialogDescription>
</DialogHeader>
<DialogHeader>
<DialogTitle>
{t("targets.edit.title", { metric: targetLabel })}
</DialogTitle>
<DialogDescription id={dialogContentId}>
{isDerivedMetric
? t("targets.edit.derivedHint")
: t("targets.edit.description", { unit })}
</DialogDescription>
</DialogHeader>
{!isDerivedMetric && primaryBounds && (
<div className="space-y-4">
{!isDerivedMetric && primaryBounds && (
<div className="space-y-4">
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-primary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-min" className="text-xs">
{isBp
? t("targets.edit.systolicMin")
: t("targets.edit.minLabel")}
</Label>
<Input
id="target-edit-min"
ref={firstInputRef}
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMin}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-max" className="text-xs">
{isBp
? t("targets.edit.systolicMax")
: t("targets.edit.maxLabel")}
</Label>
<Input
id="target-edit-max"
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMax}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-max-input"
/>
</div>
</div>
{isBp && secondaryBounds && (
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-primary-row"
data-slot="target-edit-secondary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-min" className="text-xs">
{isBp
? t("targets.edit.systolicMin")
: t("targets.edit.minLabel")}
<Label htmlFor="target-edit-dia-min" className="text-xs">
{t("targets.edit.diastolicMin")}
</Label>
<Input
id="target-edit-min"
ref={firstInputRef}
id="target-edit-dia-min"
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMin}
onChange={(e) => setMinStr(e.target.value)}
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMin}
onChange={(e) => setDiaMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-min-input"
data-slot="target-edit-dia-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-max" className="text-xs">
{isBp
? t("targets.edit.systolicMax")
: t("targets.edit.maxLabel")}
<Label htmlFor="target-edit-dia-max" className="text-xs">
{t("targets.edit.diastolicMax")}
</Label>
<Input
id="target-edit-max"
id="target-edit-dia-max"
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMax}
onChange={(e) => setMaxStr(e.target.value)}
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMax}
onChange={(e) => setDiaMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-max-input"
data-slot="target-edit-dia-max-input"
/>
</div>
</div>
{isBp && secondaryBounds && (
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
data-slot="target-edit-secondary-row"
>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-min" className="text-xs">
{t("targets.edit.diastolicMin")}
</Label>
<Input
id="target-edit-dia-min"
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMin}
onChange={(e) => setDiaMinStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-min-input"
/>
</div>
<div className="space-y-1">
<Label htmlFor="target-edit-dia-max" className="text-xs">
{t("targets.edit.diastolicMax")}
</Label>
<Input
id="target-edit-dia-max"
type="number"
step={0.1}
min={secondaryBounds.min}
max={secondaryBounds.max}
value={displayDiaMax}
onChange={(e) => setDiaMaxStr(e.target.value)}
disabled={busy}
data-slot="target-edit-dia-max-input"
/>
</div>
</div>
)}
<p className="text-muted-foreground text-xs">
{t("targets.edit.boundsHint", {
min: String(primaryBounds.min),
max: String(primaryBounds.max),
unit: primaryBounds.unit,
})}
</p>
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{hasOverride ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="target-edit-reset"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("targets.edit.resetToDefault")}
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
data-slot="target-edit-save"
>
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
<p className="text-muted-foreground text-xs">
{t("targets.edit.boundsHint", {
min: String(primaryBounds.min),
max: String(primaryBounds.max),
unit: primaryBounds.unit,
})}
</p>
</div>
)}
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{hasOverride ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="target-edit-reset"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("targets.edit.resetToDefault")}
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
data-slot="target-edit-save"
>
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
);
}
+22 -20
View File
@@ -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
@@ -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);
+12 -12
View File
@@ -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", () => {
@@ -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();
});
});
+2 -1
View File
@@ -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:
+6 -2
View File
@@ -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 });
+4 -1
View File
@@ -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<number, { type: string; factor?: number }> = {
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
+21 -21
View File
@@ -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
@@ -83,9 +83,11 @@ async function seedUser(): Promise<{ userId: string }> {
return { userId: user.id };
}
function buildClientPayload(overrides: Partial<{
chartOverlayPrefs: Record<string, unknown>;
}>) {
function buildClientPayload(
overrides: Partial<{
chartOverlayPrefs: Record<string, unknown>;
}>,
) {
return {
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({