Commit Graph
543 Commits
Author SHA1 Message Date
Marc-André Bombeck c05292f608 feat(ingest): port HK identifier mapping from k0rventen + dogsheep with MIT/Apache-2.0 attribution
Extends src/lib/measurements/apple-health-mapping.ts with the W8d
mapping surface the v1.5 iOS-Swift session needs locked before it
starts:

  - Three new APPLE_HEALTH_TYPE_MAP entries —
    HKQuantityTypeIdentifierEnvironmentalAudioExposure,
    HeadphoneAudioExposure, TimeInDaylight — wired to the matching
    MeasurementType values landed in Migration 0052.
  - HK_QUANTITY_TYPE_TO_MEASUREMENT: a compact derived view of the
    same data keyed by HK identifier so a caller that only needs the
    enum value can skip instantiating the full mapping record (XML
    import worker, iOS DTO routing).
  - HK_QUANTITY_TYPE_DEFERRED: the curated "we know about it, we are
    not mapping it yet" set. Covers iOS-17/18 running form, cycling,
    paddle / row / ski distances + speeds, sleep apnea, scored
    assessments (GAD-7 / PHQ-9), nutrition, mental-state, FHIR
    clinical, ECG, pregnancy / cycle. Each block carries its planned
    release window inline.

File-header attribution credits the two open-source projects whose
identifier lists were consulted as data —
k0rventen/apple-health-grafana (MIT) and dogsheep/healthkit-to-sqlite
(Apache-2.0); only the lookup strings were ported, not code.

Test additions
(src/lib/measurements/__tests__/apple-health-mapping.test.ts):
  - exhaustiveness — every MeasurementType outside the
    Withings-only set has an HK counterpart;
  - dedup — no identifier appears in both the mapping table and the
    deferred set;
  - identity-conversion check for the three new entries;
  - HK_QUANTITY_TYPE_TO_MEASUREMENT key + value parity with the full
    map;
  - deferred-set sanity (every entry starts with a real HK prefix).
2026-05-14 15:54:52 +02:00
Marc-André Bombeck 41ba79b5d5 feat(schema): extend MeasurementType with audio-exposure + time-in-daylight (Migration 0052)
Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:

  - AUDIO_EXPOSURE_ENV (dBA, environmental noise)
  - AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
  - TIME_IN_DAYLIGHT (minutes/day, iOS 17+)

Wire-up touches the canonical surfaces:

  - prisma/schema.prisma: MeasurementType + comment block
  - src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
  - src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
    expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
    existing v1.5 clinical-layout gate
  - src/components/measurements/measurement-list-meta.ts: i18n keys,
    Volume2 / Headphones / Sun icons, palette assignments
  - src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
    plus orphan-enum stripper coverage so the LLM cannot leak raw
    upper-snake names into rendered prose
  - messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
    typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
    fr/es/it/pl carry English placeholders matching their existing
    convention for the W5d additions)

The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
2026-05-14 15:46:56 +02:00
Marc-André Bombeck 15d91834c7 fix(api-handler): guard against private-field crash on force-static routes
Next 16's dev server passes a synthetic placeholder (not a real
NextRequest instance) as the first argument to route handlers
declared `export const dynamic = "force-static"`. NextRequest's
`method`, `url`, and `headers` getters all read a private `#state`
field; invoking those getters with the wrong `this` binding crashes
with the V8 native error:

    TypeError: Cannot read private member #state from an object whose
    class did not declare it
        at Reflect.get (<anonymous>)
        at wrapped (src/lib/api-handler.ts:56:23)

This 500s `/api/version` on every request (the only `force-static`
route in the app today) and would 500 any future force-static route
that anyone adds.

Wrap every direct NextRequest property access inside the apiHandler
wrapper in a `safeRequestProp` helper that swallows the private-field
error and returns a sensible fallback (empty string for `url`, "GET"
for `method`, `null` for header lookups). The fallbacks let logging
instrumentation continue to attach `path` / `method` / `user_agent`
fields when they are available and silently degrade when they are
not — instrumentation must never crash a route handler.

Verification: `curl -sI /api/version` -> HTTP/1.1 200 OK, body
contains the package.json version (was 500). Dynamic routes
(`/auth/login`, `/api/health`) still serve correctly. Affected
tests still pass (vitest src/lib/api-handler + src/app/api/version
+ src/components/targets = 31/31).
2026-05-14 15:40:50 +02:00
Marc-André Bombeck 3c4bbdef4f feat(i18n): MaintainershipBanner notice + foundation for 4 new locales
Add the four AI-initial locale bundles (FR / ES / IT / PL) and a
small dismissible MaintainershipBanner strip that paints at the top
of the auth shell whenever the active locale falls outside the
maintainer-curated set (currently DE + EN). The banner names the
AI-initial provenance and links to a GitHub translation-feedback
template so users can contribute fixes back. Dismissal persists
per-locale in localStorage so dismissing the FR banner does not
hide the IT banner once the user switches language.

The bundles are generated by scripts/i18n/build-locale.py — a layered
walk of messages/en.json that applies a vocabulary lookup keyed on
the lowercase EN value, preserves every {interpolation} placeholder
and <tag> marker verbatim, and falls through to the English value
for long-tail prose the script can't translate. The banner advertises
this status so users know what they are looking at. All four locale
files pass the i18n-locale-integrity test verbatim — full key
parity to en.json, no empty values, no TODO/FIXME placeholders,
no value-equals-last-segment matches.

Foundation work bundled with the banner so the build never lands in
a half-wired state:
- locales array extended to ["de","en","fr","es","it","pl"] with
  matching localeLabels + MAINTAINED_LOCALES set + isMaintainedLocale
  helper in src/lib/i18n/config.ts.
- INTL_LOCALE_MAP and parseLocaleFromAcceptLanguage in
  format-locale.ts gained the four new tags so SSR locale resolution
  and Intl number/date formatting work end-to-end.
- i18n/context.tsx and i18n/server-translator.ts import the four new
  JSON bundles alongside DE + EN.
- Coach (src/lib/ai/coach/system-prompt.ts) and the strict insights
  generator (src/lib/ai/prompts/insight-generator.ts) ship the EN
  system prompt with a one-line REPLY LANGUAGE footer for the four
  AI-initial locales. The DE branch keeps its hand-curated body
  verbatim. Routing through the EN system prompt preserves every
  safety contract (zero hallucinations, no dose-prescription,
  evidence-block sentinel format) without re-validating four new
  language bodies.
- Five locale-parameter signatures (no-key-fallbacks, glp1-plateau,
  time-window-format, target-prompts, medication-form) widened
  from "de" | "en" to the canonical Locale type. Helpers that
  ship DE+EN copy route non-DE locales to the EN body, mirroring
  the same fallback chain the JSON bundles use.
- OpenGraph alternateLocale on the root layout lists the four new
  BCP-47 tags so social-link previews advertise the broader
  language coverage.

Tests:
- 4 new SSR tests pin the locale gate (EN/DE hidden, FR/ES/IT/PL
  visible by default, dismissed flag honoured) and the re-exported
  isMaintainedLocale helper.
- parseLocaleFromAcceptLanguage gains a FR/ES/IT/PL coverage block
  alongside the existing DE / EN / unknown branches.
- The existing i18n-locale-integrity suite auto-discovers each new
  messages/<locale>.json and runs the 25-sub-test parity + value-
  quality battery against it — all six locales pass.

LocalePicker is data-driven (settings and onboarding both iterate
the locales array) so promoting the array to 6 entries grows both
dropdowns without per-call-site changes. The PDF doctor-report
renderer is t()-driven for every label, so the four new JSON
bundles give that renderer FR/ES/IT/PL output without touching
doctor-report-pdf-core.ts.
2026-05-14 15:40:34 +02:00
Marc-André Bombeck 782731aeda fix(css): repair Tailwind v4 color-mix parser crash on dev server
Tailwind v4's content scanner walks the entire project root by default
and emits a CSS rule for every class-shaped token it finds — including
literal class fragments quoted inside .planning report markdown and
docs/ design specs. The bracketed CSS-variable arbitrary-value form
combined with an opacity modifier (e.g. `bg-[var(--name)]/12`)
generates an escaped selector whose `\(...\)\/N` token sequence
crashes Turbopack's CSS parser with `Unexpected token Delim('.')`,
500-ing every page that imports globals.css.

The crash:
    ./src/app/globals.css:1853:27
    Parsing CSS source code failed
      1852 |   .bg-\[var\(...\)\]\/12 {
    > 1853 |     background-color: var(...);
           |                           ^

Three coordinated changes:
- Add `@source not "../../.planning"` and `@source not "../../docs"`
  to globals.css so the scanner skips planning + docs trees. Stops
  fixture-style class names in research/audit reports from poisoning
  the generated CSS.
- Migrate `bg-[var(--dracula-*)]/N` / `ring-[var(--dracula-*)]/N` in
  target-card.tsx to the Tailwind v4 parenthesised shorthand
  `bg-(--dracula-*)/N` / `ring-(--dracula-*)/N`. The shorthand emits
  a clean selector that LightningCSS parses cleanly. Per the Tailwind
  v4 upgrade guide, the bracketed form is deprecated for CSS
  variables.
- Replace `shadow-[0_0_0_1px_var(--dracula-green)]/20` in
  consistency-strip.tsx with `shadow-[0_0_0_1px_color-mix(in_oklab,var(--dracula-green)_20%,transparent)]`.
  The arbitrary shadow with opacity modifier hits the same parser
  issue; encoding the alpha directly in the value via color-mix
  removes the modifier and keeps the visual identical.

Verification: `pnpm dev --port 3030` then `curl -sI /auth/login` ->
HTTP/1.1 200 OK (was 500). Zero CSS parse errors in the dev server
log. Affected tests (targets + api-handler + api/version) still pass
(31/31).
2026-05-14 15:39:39 +02:00
Marc-André Bombeck 53278ccd87 test(sources): integration tests for two-axis canonical-source resolution
Lock the GET /api/analytics wire-up to the new two-axis picker with a
fixture-driven test that drives a real Postgres testcontainer + the
production handler. Four scenarios on the same Berlin day:

  1. Plain default user (single source, no override) — picker passes
     through; v1.4.25-today behaviour stays a regression sentinel.
  2. Per-metric override (sleep: [WITHINGS, APPLE_HEALTH]) — flips
     the default APPLE_HEALTH-first ladder; Withings row wins.
  3. Per-device default (no user override) — Apple Watch row beats
     iPhone row under same source on default ladder (watch first).
  4. User flips device ladder to [phone, watch] — iPhone row wins.

Wire-up: analytics' fetchMeasurementSeriesChunked now selects
deviceType + type on every chunk so the picker can read both axes.
ChunkedRow gains two fields; all existing aggregation paths continue
to compile and the integration suite stays green (6/6 passing across
analytics-sleep-stages.test.ts + source-priority-two-axis.test.ts).

Unit tests already cover the picker's internal edge cases (45 unit
across helper + algorithm); this file is the end-to-end guard.
2026-05-14 15:36:42 +02:00
Marc-André Bombeck 024845e5bd feat(api): nullable deviceType on Measurement for two-axis source priority
Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.

Backward-compatible end-to-end:
  - Schema field is optional; legacy iOS builds keep working unchanged.
  - DB column is nullable; the migration is an additive ALTER TABLE
    (Migration 0051, next free per v1.4.25 handoff section 4).
  - The picker treats null as 'unknown' and only uses it as a
    tiebreaker when a ranked device-type coexists in the bucket; with
    a single source + null deviceType the row passes through as today.

OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.

Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.

Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
2026-05-14 15:34:18 +02:00
Marc-André Bombeck 27f06c1576 feat(settings): per-metric + per-device override UI for Source Priority
Extend Settings → Sources with two collapsed-by-default expanders below
the existing global ladder (Marc no-split directive — one screen, three
vertically-stacked sections):

  1. Per-metric overrides — surfaces every metric whose ladder has been
     edited away from the default; each row reveals a one-click reset
     so a single metric can roll back without touching the rest.
  2. Device-type priority — the new W8c axis. Default ladder uses the
     open-wearables-compatible 'watch beats phone beats scale' order;
     up/down arrows + 'Reset device-types' keep the same interaction
     vocabulary as the existing source ladder.

Save round-trips the W8c canonical shape (metricPriority +
deviceTypePriority); the server's parseSourcePriority already echoes
the resolved shape back so the optimistic update is a setQueryData.
Reset PUTs an empty body so the server returns constant defaults.

i18n: 11 new keys under settings.sections.sources.* (en + de). German
prefers Umlaute (Uhr, Geräte-Typ-Reihenfolge, individuelle Anpassungen).

Tests: settings-shell.test.tsx already gates the navigation
ordering — keeps passing (12/12). No new component tests yet; the
integration coverage in 8c.5 exercises the new shape end-to-end.
2026-05-14 15:31:44 +02:00
Marc-André Bombeck 574376024c chore(query-keys): drop insightsGeneralStatus leftover from W3f route removal
The `/api/insights/general-status` route was deleted as part of W3f
(commit edae569) when the general-status panel migrated into the
combined comprehensive insights payload. The matching query-key factory
+ its integrity test assertion were missed in that cleanup pass.

The test now pins `insightsBpStatus` instead — the locale-in-status-key
invariant remains covered.
2026-05-14 15:30:17 +02:00
Marc-André Bombeck 94e048f3b5 chore(charts): remove unused ComplianceCharts wrapper
The wrapper composed `<ComplianceHeatmap />` and `<ComplianceLineChart />`
into a single panel, but no surface ever rendered the wrapper —
analytics pages mount the two child charts directly. The children stay
intact; only the dead wrapper is removed.
2026-05-14 15:30:09 +02:00
Marc-André Bombeck 784105d3bb chore(medications): remove unused IntakeTimeline component
The component was exported but never imported anywhere — only the file
itself contained the three references (the props interface, the
function declaration, and the type annotation). Likely an early scaffold
for the v1.4 medication-intake redesign that was superseded by the
intake-card layout before any caller wired it in.

No tests existed for this component, so the delete is single-file.
2026-05-14 15:30:05 +02:00
Marc-André Bombeck 1149ae5021 chore(insights): remove @deprecated InsightsPageHero (zero callers since v1.4.20)
`<HeroStrip>` (v1.4.20 phase B1) replaced this component on every
surface. Last production import was retired with the dashboard rewrite
and only the deprecated source + its self-test remained. Comment
markers in `hero-strip.tsx` already note the migration.

Pre-existing typecheck failures in the working tree (locale enum drift
from concurrent W9e i18n work in medication-card / medication-form /
target-card) are unrelated to this delete and stay as-is.
2026-05-14 15:29:57 +02:00
Marc-André Bombeck 42d88fa5a8 feat(sources): pickCanonicalSource — two-axis lookup with single-axis fallback
After picking the canonical source for a day, the picker now consults a
device-type ladder and keeps ONLY the rows from the top-ranked device-
type present in the day's bucket. Rationale: Apple Watch + iPhone +
Withings scale all stream steps into HealthKit; summing the three
triple-counts the daily total. The watch is the most reliable signal,
so the picker keeps the watch rows and drops phone/scale.

Lookup order (high to low): deviceTypePriority[metricType] (per-metric
override) > deviceTypePriority.default (user default) >
DEFAULT_DEVICE_TYPE_PRIORITY (constant). When no device-type info is
present (legacy NULL rows / pre-W8c data), the second axis is a no-op
— every row in the bucket passes through. Custom ladders that don't
enumerate the bucket's device-types also fall through unfiltered, so
the picker never silently drops data.

SourcePickerRow gains two optional fields (deviceType, type). Both
default-null so existing call sites continue to compile.

Tests: 18 cases pass (8 single-axis legacy + 10 new W8c two-axis).
Covers per-metric override, custom ladder, legacy NULL fallback,
preference of known over unknown, and the W8c-over-W5e shape merge.
2026-05-14 15:27:51 +02:00
Marc-André Bombeck f05c55f638 feat(sources): two-axis priority — additive metricPriority + deviceTypePriority Zod keys
Layer two new optional keys on top of the existing User.sourcePriorityJson
shape — metricPriority (nested per-metric ladder, canonical going forward)
and deviceTypePriority (default ladder plus per-metric overrides). The
W5e flat top-level metric keys stay accepted as a backward-compat alias
so existing rows keep working without a DB migration or forced reconfig.

parseSourcePriority() now returns a fully-resolved shape where every
metric ladder is defaulted and the device-type axis defaults to the
documented DEFAULT_DEVICE_TYPE_PRIORITY (watch first). Two helpers
(getSourceLadder, getDeviceTypeLadder) centralise the lookup so the
picker, the UI and downstream call sites share one path.

Tests: 30 unit tests pass (up from 19). Covers both axes' parse,
fallback chain (W8c nested > W5e flat > defaults), enum guards, and
the two helper functions.

Pre-existing typecheck failures unrelated to this change:
src/lib/i18n/* and a handful of consumers expect a 'de' | 'en' locale
union but i18n config declares the 6-locale superset.
2026-05-14 15:24:23 +02:00
Marc-André Bombeck 8f54993df5 style(layout): unify top-of-page padding between AuthShell, Settings, and Admin
Settings and Admin pages render inside AuthShell, whose inner main
already applies `mx-auto max-w-[76.8rem] px-4 py-6 md:px-6`. Both
shells then re-applied `px-4 py-6 md:px-6 md:py-8` on their own
container, which produced visibly more top/bottom whitespace on
/settings/* and /admin/* than on /, /measurements, /medications,
/mood, /insights, /coach, and /notifications.

Drop the redundant padding from SettingsShell and AdminShell so the
visual top-of-page baseline matches the rest of the app. The
`mx-auto w-full max-w-screen-xl` wrapper stays for the wider
content column that these two surfaces opt into. Mobile chip-strip
`-mx-4` still cancels AuthShell's horizontal padding correctly.
2026-05-14 15:15:08 +02:00
Marc-André Bombeck 5822c873f7 fix(mobile): hit WCAG 2.5.5 44 px touch-target floor across top-bar + section strips
Pixel-5 (393 px) audit flagged four primary navigation surfaces that
were below the 44 px touch-target floor:

  - TopBar user-menu trigger and login link: text-only flex with
    py-1.5 measured ~30 px tall. Added min-h-11 min-w-11 with a
    rounded hit zone so the trigger lands on the WCAG floor without
    visibly altering the icon's position.

  - SettingsShell mobile section chip-strip and AdminShell mobile
    section chip-strip: rounded-full chips with py-1.5 were ~30 px
    tall. The mobile chip-strips are the primary navigation surface
    for /settings and /admin on touch devices, so min-h-11 was added
    to both.

  - medication-form schedule interval picker (1W / 2W / 3W / 4W):
    h-8 native buttons inside a 4-col grid; min-h-11 brings them up
    to the floor without changing column width.

Also normalised the medications page-root spacing from space-y-5 to
space-y-6 to match dashboard / measurements / mood / notifications.
2026-05-14 15:12:54 +02:00
Marc-André Bombeck 91d5db5853 fix(admin): align Coach-Feedback header with adjacent sections to prevent layout shift
The Coach-Feedback admin section previously rendered four distinct top-
level structures across its loading / error / empty / data branches.
The loading branch was a thin flex-row stub without the heading, the
error branch was a p-4 alert with no card chrome at all, and only the
empty + data branches mounted the Sparkles icon + 'Coach Feedback'
title inside the bg-card border-border rounded-xl border p-6 wrapper.
The result: when the user clicked into /admin/coach-feedback the
section first measured at ~52 px (loading) and then snapped down to
its full height once the query resolved, shifting everything below it.

Mirror the canonical pattern used by SystemStatusSection: render the
outer card + heading unconditionally, and switch only the body between
spinner / alert / empty / table. The header now sits at a constant
y-offset from the section root regardless of fetch state.

Adds a regression test that asserts the 'Coach Feedback' heading and
the bg-card rounded-xl outer chrome are present in every render state.
2026-05-14 15:09:16 +02:00
Marc-André Bombeck 00105e25f7 fix(settings): add icon to notification-status-card heading for cross-section parity
Both heading variants (empty-state at line 126, header at line 139) now
follow the convention used by 21 other Settings sections: a Bell icon
(text-primary h-5 w-5) preceding the h2 in a flex container with
gap-2 items-center. Brings the only two violators in
src/components/settings/* into alignment with the icon-heading pattern
established in telegram-card.tsx, ntfy-card.tsx, web-push-card.tsx.
2026-05-14 15:07:11 +02:00
Marc-André Bombeck ca3c225a07 fix(measurements): wire W5d Withings enum values into list-meta + chart-tokens
The 7 W5d additions (fat-free mass, fat mass, muscle mass, skin
temperature, pulse-wave velocity, vascular age, visceral fat) landed
in measurementTypeEnum but the per-type lookup maps and AI-chart
allowlist were never extended. Six drift-guard tests caught the gap.

Adds icons (Dumbbell for muscle; family-consistent reuse for the
rest), chart-bucket colors (mass → chart-1, fat/temp → chart-4,
cardio → chart-3, pulse-derived → chart-5), AI chart-token allowlist
entries, and orphan-enum strip list entries so raw enum identifiers
can never leak through prose. Doctor-report PDF holds the 7 new
types under the same v1.5 gate as the v1.4.23 Apple Health metrics
until clinical layout + reference ranges are agreed.

Baseline: 2537 → 2543 passing tests; typecheck clean.
2026-05-14 14:59:01 +02:00
Marc-André Bombeck 98f7d11861 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.
2026-05-14 14:19:08 +02:00
Marc-André Bombeck 7c720e8e9f test(medications): RTL coverage for injection-site picker + recommender
Closes the second half of the deferred phase-W4d test gap. The
picker is a controlled SVG with 8 click-targets wrapped in
invisible 22px circles carrying role=button + aria-label +
tabIndex=0 (WCAG 2.5.5 minimum-tap-target + keyboard-reachable).
The nextInjectionSite() helper return is dashed-ring annotated
over the recommended target.

Coverage (12 tests):
- All 8 InjectionSite enum values render as click-targets
- Active site gets the fill-primary highlight
- Recommended next-site is dashed-ring annotated
- onChange(site) fires for every enum value
- Keyboard navigation: tabindex=0 on every target
- ARIA labels EN + DE smoke (Umlaute round-trip)
- aria-pressed=true on the selected site, false on the others
- Empty history → recommender defaults to ABDOMEN_LEFT
- Caption swap between selection line and recommendation hint
- SVG body outline + role=group wrapper

No production code changes.
2026-05-14 14:14:30 +02:00
Marc-André Bombeck f9621e483b test(medications): RTL coverage for GLP-1 medication-card variant
Closes the deferred component-level test gap from phase W4d
(`.planning/phase-W4d-v1425-glp1-full-report.md` section 5).
Renders the Glp1MedicationCard via renderToStaticMarkup with a
seeded QueryClientProvider so the useQuery calls resolve
synchronously, then asserts on the produced SSR string.

Coverage (13 tests):
- GLP-1 variant renders when treatmentClass === "GLP1"
- Default MedicationCard back-compat when treatmentClass is null
- Drug name + dose joined with the middle-dot separator
- Last + next injection lines with localised prefixes
- Dose-history <details> disclosure (closed by default)
- Injection-site rotation hint (last + recommended)
- Pen-inventory line + low-stock badge
- Side-effect quick-log button hands off the medication object
- AI-disabled state pin (no Coach hand-off button today)
- Inactive paused-since state
- German locale parity with Umlaute round-trip

No production code changes.
2026-05-14 14:14:18 +02:00
Marc-André Bombeck 5a9612822e feat(withings): OAuth scope upgrade + reconnect banner
v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the
`user.activity` OAuth scope which v1.4.24-and-earlier connections
never requested. This commit ships the scope plumbing + reconnect
surface so existing users can opt in to step / active energy /
distance / floors-climbed ingest with a single click.

  - Schema: `WithingsConnection.scope` nullable column. NULL =
    legacy connection that's never re-authed since v1.4.25.
  - Migration 0050 — additive column add.
  - Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"`
    + `parseWithingsScope` / `hasActivityScope` helpers.
  - Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every
    successful exchange, so reconnecting upgrades the row.
  - Status route: exposes `scope` + `hasActivityScope` so the UI
    can render the reconnect banner conditionally.
  - Settings → Integrations: paint a reconnect banner when the
    user has an active connection AND `hasActivityScope === false`.
    Click → /api/withings/connect → standard OAuth flow.
  - i18n: EN + DE strings for the banner +
    `measurements.types.{fatFreeMass,fatMass,muscleMass,
    skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`.
  - 5 new unit tests covering the scope helpers.

Note: the Activity sync routine itself
(`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the
scope upgrade now means users only reconnect once instead of twice.

Cross-source dedup protection is already in place via the W5e
foundation (`pickCanonicalSourceRows`): once Activity sync lands AND
a user also has Apple Health passthrough (v1.5), the per-day picker
prevents double-counting cumulative metrics from two sources.
2026-05-14 14:13:58 +02:00
Marc-André Bombeck 7846d68dd5 feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle)
Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).

Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
  builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
  days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
  the visualisation. Default window = 7d; 14d + 30d toggles in the
  card header (same min-h-11 touch targets as the dashboard range
  tabs). Y-axis renders hours, tooltips show per-stage minutes +
  percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
  screens, sit inline on sm+. Bars use Recharts vertical layout
  (per the directive — no horizontal reflow on mobile, just tighter
  XAxis labels via preserveStartEnd).

i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.

Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
2026-05-14 14:08:56 +02:00
Marc-André Bombeck c50931b4e7 feat(schema): MeasurementType enum additions for Withings full coverage
Seven new enum values for v1.4.25 W5d. Source: Withings developer
guide — Data API → All Available Health Data — cross-referenced
against `.planning/research/withings-api-coverage.md` §3.

  - FAT_FREE_MASS (meastype 5) — Body+, Body Cardio, Body Comp, Body Scan
  - FAT_MASS (meastype 8) — same devices; mass form of BODY_FAT
  - MUSCLE_MASS (meastype 76) — Body+ family
  - SKIN_TEMPERATURE (meastype 73) — ScanWatch dermal reading. DISTINCT
    from BODY_TEMPERATURE: surface temps run ~32 °C, core ~37 °C, so
    they cannot share a bucket without corrupting analytics.
  - PULSE_WAVE_VELOCITY (meastype 91) — Body Cardio / Body Scan;
    indicator of arterial stiffness
  - VASCULAR_AGE (meastype 155) — Body Scan; composite of PWV + age
  - VISCERAL_FAT (meastype 170) — Body Comp / Body Scan; Withings'
    1–12 rating scale (not a percent)

Migration 0049 is additive only — every value is unconditional, no
backfill needed. The Withings client picks these values up once the
meastype → enum mapping lands (separate commit in this phase).

Unit map + plausibility ranges updated in `src/lib/validations/
measurement.ts` so the create-measurement validator accepts the new
types end-to-end.
2026-05-14 14:07:16 +02:00
Marc-André Bombeck 03f58c75b8 docs(planning): W4d v1.4.25 GLP-1 full integration phase report 2026-05-14 14:04:53 +02:00
Marc-André Bombeck d74aa2066b style(glp1): prettier formatting across W4d surfaces 2026-05-14 14:03:43 +02:00
Marc-André Bombeck a68dbdc22c chore(glp1): test-environment tolerance + i18n parity + OpenAPI regen
Final-pass hygiene for the W4d GLP-1 integration:

- `buildGlp1SnapshotBlock()` and `detectGlp1Plateau()` short-circuit
  to `null` when the test environment mocks prisma partially (only
  `measurement` / `moodEntry` mocked, `medication` undefined). Matches
  the production behaviour for accounts without an active GLP-1 row.
- DE locale parity: adds the matching German string for
  `insights.subPage.therapyTimelineDescription` that landed without
  its de.json counterpart.
- Drops unused `locale` destructure on the GLP-1 medication card and
  the dead `DailyValueRow` interface in the snapshot helper.
- Regenerated `docs/api/openapi.yaml` so the new GLP-1 endpoints
  (/api/medications/[id]/glp1, /api/insights/glp1-timeline) appear
  in the spec.
2026-05-14 14:01:42 +02:00
Marc-André Bombeck f45adb29f0 feat(doctor-report): GLP1 therapy section with titration + weight curve + side-effects
Phase 10 of W4d GLP-1 full integration. The doctor-report PDF gains a
dedicated "GLP-1 therapy" section when the patient has at least one
active GLP-1 medication and the existing compliance toggle is on. The
section lists what a prescribing clinician needs at every follow-up:

- Weight delta over the report window (start → end → delta)
- Per-medication block: drug name, current dose + start date,
  compliance count and rate, full titration history table
  (date | dose | note)
- Side-effect frequency table (last N days, counts per tag) when the
  mood section is also enabled (mood is the data source)

Generic accounts (no GLP-1 medication) get `glp1: null` from the
aggregator and the PDF skips the entire section, so existing reports
stay byte-identical.

Aggregator extension lives in `doctor-report-data.ts` — eager-loads
`doseChanges` + the most recent intake event on every active
medication (cheap, indexed); GLP-1 specifics only assemble when at
least one GLP-1 row exists. The field is optional on the type to
preserve compatibility with existing test fixtures.

DE + EN copy for the report section lives under `doctorReport.glp1*`.
2026-05-14 13:56:08 +02:00
Marc-André Bombeck 7b6b9161c3 style(charts): grey out comparison-overlay toggle when no prior-period data exists
Marc directive 2026-05-14 — when a chart has a comparisonBaseline
selected but the prior period (lastMonth / lastYear) carries zero
overlay-able rows, the chart settings cog now shows the comparison
buttons in a visibly disabled state (opacity-50, aria-disabled, native
title tooltip, and a sub-label hint) instead of silently rendering
nothing. The 'none' button always stays enabled — it's the escape
hatch.

Plumbing:
- chart-overlay-controls.tsx: new `hasComparisonData` prop (defaults
  to true so legacy callers keep their behaviour). The popover body is
  extracted as `<ChartOverlayControlsBody>` so unit tests can render
  it without Radix's portalled popover stripping it out of the SSR
  output.
- health-chart.tsx + mood-chart.tsx: thread the already-computed
  `hasComparisonData` memo (line 768 / 457) into the controls.
- medication-compliance-chart.tsx: passes `hasComparisonData={false}`
  unconditionally because the compliance heatmap never paints a
  prior-period overlay (already documented in the existing N/A
  caption block).

Tests: 5 new cases under chart-overlay-controls.test.tsx cover the
normal / greyed / none-baseline / locale / default-prop branches.
The pre-W3f assertion suite (3 tests) keeps passing unchanged.

i18n: appended chart.overlay.controls.comparisonUnavailable to both
locales — no key restructure.
2026-05-14 13:53:42 +02:00
Marc-André Bombeck 6148a179ca feat(briefing): GLP1 plateau detection rule with clinical referral framing
Phase 9 of W4d GLP-1 full integration. New `detectGlp1Plateau()`
helper flags the clinical plateau pattern for GLP-1 receptor agonists:
≥21 days on a stable dose AND weight delta within ±0.5 kg over that
same window. When the rule fires, a SYSTEM CONTEXT block is appended
to the insight-generation user prompt with the named drug, current
dose, days-on-dose, and weight delta — and instructs the model to
emit a glp1_plateau keyFinding framed observationally.

GROUND RULE 14 (insight prompt) keeps the safety contract: the
finding NAMES the plateau and the conversation it should prompt
("worth mentioning at the next visit"), but NEVER recommends a dose
change. The plateau is detection, not prescription.

Detector returns `null` when no GLP-1 medication is active OR no
plateau condition is met, so 99% of users pay zero token cost and
never see a plateau-shaped section in their briefing.

The schema gains "glp1_plateau" in the keyFinding.sourceMetric enum
(landed earlier this phase) and the daily-briefing icon mapping
inherits the Pill icon for visual consistency with other
medication findings.
2026-05-14 13:52:31 +02:00
Marc-André Bombeck 2c7c3cc9f3 feat(insights): GLP1 therapy timeline on /insights/medikamente
Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.

Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.

New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.

DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
2026-05-14 13:49:35 +02:00
Marc-André Bombeck edae56969f chore(insights): drop orphaned /api/insights/general-status — superseded by InsightAdvisorCard since v1.4.16
The HTTP route had no remaining UI callers — the v1.4.16 polish replaced
the <InsightStatusCard> on the /insights overview with the polished
<InsightAdvisorCard>, and the per-metric sub-pages each consume their
own dedicated endpoint (/api/insights/{weight,bmi,pulse,blood-pressure,
medication-compliance}-status). The lib helper generateGeneralStatusForUser
remains in src/lib/insights/general-status.ts because the reminder
worker still pre-warms the daily cache for users opted in to the
briefing notification (src/lib/jobs/reminder-worker.ts).

Verification:
- grep confirms zero callers under src/ outside the route file itself
- the route was not registered in src/lib/openapi/routes.ts and
  docs/api/openapi.yaml carries no general-status path
- pnpm typecheck remains clean
2026-05-14 13:49:06 +02:00
Marc-André Bombeck 6750d091b9 feat(medications): injection-site body-map picker + rotation recommender
Phase 5 of W4d GLP-1 full integration. The body-map picker is the
visual surface for the 8-zone InjectionSite enum that landed with
migration 0046. Stylized front-of-body SVG outline with 8 click-target
dots — head/neck/torso/arms/thighs for orientation, abdomen
quadrants centred on the navel reference per Eli Lilly's IFU rotation
zones, plus thighs and upper arms.

UI behaviour:
- Active site fills with the primary colour
- Recommended next site shows a dashed primary-coloured ring
- Each <button> hit area is 22px radius so the target meets WCAG 2.5.5
  even though the visible dot is 12px

`nextInjectionSite()` is a pure function that maximises the average
Euclidean distance from the candidate to the last N=4 picks, with a
recency penalty for sites used most recently. Untouched sites beat
stale ones via the recency-bonus tie-break. Empty history returns
ABDOMEN_LEFT as a sensible first-time default (the card always has
something to point to).

Unit tests cover: never-recommends-most-recent invariant, sensible
choice after a left-cluster sequence, rotation through unused sites,
i18n key shape, coordinate-table completeness.
2026-05-14 13:39:01 +02:00
Marc-André Bombeck 36bf96059e style(coach): prettier formatting on W5 surfaces 2026-05-14 13:37:41 +02:00
Marc-André Bombeck 88efd42755 feat(dashboard): GLP-1 status tile with next-injection + weight-delta caption
Renders a dedicated tile for users on a GLP-1 receptor agonist
(Mounjaro, Ozempic, Wegovy, Zepbound, …) — the new W4d schema enum
`Medication.treatmentClass = GLP1` is the discriminator. Tile self-
hides when no active GLP-1 med exists so accounts without one see no
visual change.

The tile surfaces:
  - Drug name + current dose ("Mounjaro 7.5mg") — sourced from the
    `MedicationDoseChange` titration history (latest row wins), so a
    Mounjaro user titrating up 2.5 → 5 → 7.5 → 10 mg sees their
    current step, not the starting dose.
  - Last injection date + weekday ("Sun, 05/10")
  - Next injection date + weekday + countdown
    ("Sun, 05/17 (in 3 days)") — predicted from the existing schedule
    cadence
  - Weight delta since starting the therapy ("−4.2 kg since start"
    / "−4,2 kg seit Beginn") — green chip on loss, orange on gain,
    muted minus on flat (within ±0.1 kg)
  - A compact weight chart with vertical injection-day markers via
    the new `<HealthChart verticalMarkers={…} />` prop

New server endpoint `GET /api/dashboard/glp1` aggregates the data:
the heavy lifting (current dose / last + next injection / titration
history / inventory math) is delegated to the existing
`buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
the dashboard tile read from the same shape. The route adds the UI-
facing extras (weight series since first dose-change, current vs
start, injection date keys for the markers) on top.

Mount: dashboard mother page (`src/app/page.tsx`) mounts the tile
below the AI insights preview and above the chart row — high enough
that a Mounjaro user sees their dose-response without scrolling past
BP / pulse, but below the AI preview which stays the scroll-stop
hero anchor.

Constraint respected (Marc directive 2026-05-14): the chart lives on
the Dashboard tile and the Insights /medikamente sub-page only; the
Medications page itself stays chart-free.

i18n keys: dashboard.glp1.{title, lastInjection, nextInjection,
weightDelta, inDays, empty.title, empty.description} in EN + DE.

Tests pin 7 scenarios: hidden when no active GLP-1, hidden when meds
array is empty, skeleton during fetch, full render for a Mounjaro 7.5
mg weekly profile, latest titration step wins (10 mg, not 2.5 mg),
fallback to name-only when no dose history exists, German copy under
the de locale.
2026-05-14 13:37:29 +02:00
Marc-André Bombeck b8a1c1833b feat(medications): GLP1 medication-card variant — text-rich, no chart per Marc directive
Phase 4 of W4d GLP-1 full integration. New `Glp1MedicationCard`
variant renders inside the existing medications grid alongside generic
cards — same Card shell, same compliance bars, same primary intake
actions. The GLP-1-specific rows sit between the dose line and the
compliance bars so the user reads top-down: drug → injection state →
adherence.

Marc directive 2026-05-14: NO chart on the medication card. The card
stays text-rich:
- Headline: drug name + current dose ("Mounjaro · 7.5 mg")
- Last injection line with optional site ("Last: Mo May 12 · Abdomen,
  lower left")
- Next injection line with day count ("Next: Mon, May 19 (in 5 days)")
- Rotation hint ("Last site: Abdomen left · Recommended:
  Abdomen right") — only when the last site differs from the
  recommended next site
- Pen-inventory line with low-stock badge when dosesPerUnit is set
- Collapsible "Dose history" details block listing every
  MedicationDoseChange row
- "Log side effect" quick-button alongside Taken / Skipped

Routing: the medications page reads `treatmentClass === "GLP1"` and
dispatches to the variant. Existing meds (treatmentClass = "GENERIC",
the migration 0046 default) render the unchanged generic card.

Companion pieces:
- `injection-sites.ts` — the 8-site enum + `nextInjectionSite()`
  recommender that maximises Euclidean distance to recent picks and
  beats stale sites in the tie-break.
- `/api/medications/[id]/glp1` — GLP-1 details endpoint feeding the
  card (dose changes + recent intakes + running inventory math).
  POST creates a dose-change or inventory event.
2026-05-14 13:36:30 +02:00
Marc-André Bombeck b50ebccf0b feat(coach): distinct daily-limit vs provider-rate-limit error UX
`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
2026-05-14 13:35:57 +02:00
Marc-André Bombeck 94cc7f571d feat(medications): once-weekly cadence preset for GLP1 category
Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:

- "Once weekly on …" preset — a single-tap weekday row that writes
  daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
  the advanced block so the user can still tune the window. The last
  weekday picked persists in localStorage so a follow-up med starts on
  the same day.
- "Doses per pen / vial" optional input that powers the inventory math
  (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
  pen = 1). Leaving it blank keeps inventory tracking off.

The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.

Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
2026-05-14 13:33:18 +02:00
Marc-André Bombeck c7eb226969 feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern
The composer starts at a single-line height (matching the disclaimer
text on the left of the row) and grows with content up to six lines.
Past that point the textarea scrolls internally so the rest of the
drawer never moves. Implementation is the standard scrollHeight
measurement — reset to "auto", read scrollHeight, clamp to
[lineHeight + padding, 6 × lineHeight + padding]. The pure
`computeAutoGrowHeight` helper backs the effect for unit testing.
2026-05-14 13:32:28 +02:00
Marc-André Bombeck 1332342c59 feat(charts): weight chart accepts vertical injection-day markers
Adds an optional `verticalMarkers` prop to `<HealthChart>` so the
v1.4.25 GLP-1 dashboard tile + the Insights /medikamente sub-page can
paint dashed vertical reference lines at each recorded injection day.
Each marker = `{ date: 'YYYY-MM-DD', label?, color? }`. Defaults to
the strip-tile green (`#50fa7b`) so a row of markers reads as
"injection cadence" at a glance; callers can override per marker.

Rendering:
  - Recharts `<ReferenceLine>` with `strokeDasharray='3 3'`,
    `strokeOpacity=0.55`, no label by default (inline labels would
    crowd a once-weekly cadence over a 90-day window).
  - A small filled `<ReferenceDot>` (r=3) at the y-axis minimum so
    the markers also read along the x-axis baseline.
  - Off-window markers silently drop via `ifOverflow='discard'`,
    matching the existing storyboard-annotation contract.

The marker math is factored into a pure helper
`resolveVerticalMarkerPositions` so the unit suite can pin behaviour
without mounting Recharts. Day-keys are matched by EXACT string
comparison against `chartData[i].date` (no ±7-day snap like the
storyboard annotations) — drawing a marker on a day the chart has no
data point reads as a rendering bug rather than as orientation.

Tests pin: empty input, undefined input, single in-window match,
multi-marker order, caller-supplied color/label overrides, off-window
drop, sparse weekly-bucket exact-day matching.
2026-05-14 13:31:41 +02:00
Marc-André Bombeck 70fe5a55c2 refactor(coach): drop non-functional mic icon from composer
The mic was rendered + disabled with a "voice arrives with iOS"
tooltip, drawing taps that did nothing. Voice input remains scheduled
for the iOS client (v1.5); the web composer no longer surfaces a
placeholder. Frees the row for the Enter-to-send hint to breathe.
2026-05-14 13:31:12 +02:00
Marc-André Bombeck 64d50fd32e i18n(doctor-report): EN + DE strings for section toggles 2026-05-14 13:30:39 +02:00
Marc-André Bombeck 81b1d79232 style(coach): unify X / cog / new-chat header buttons — same size + alignment
All three header actions (new chat, settings, close) now share the
same ghost / size-icon / 36-px hit-target shape with a 16-px icon and
identical color treatment. The Sheet's default absolute-positioned
close X is gone; the inline close button sits on the same baseline as
the others so the cluster reads as a single set.
2026-05-14 13:30:12 +02:00
Marc-André Bombeck 759fdaefcd feat(doctor-report): server filter respects per-section toggles + PDF skips disabled sections 2026-05-14 13:29:53 +02:00
Marc-André Bombeck 330be965ac feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
2026-05-14 13:28:10 +02:00
Marc-André Bombeck 2bd29022b1 feat(coach): snapshot weeklyContext.glp1 names the drug + dose + schedule
Phase 2a of W4d GLP-1 full integration. New `buildGlp1SnapshotBlock()`
helper reads every active medication classified as GLP1 (treatmentClass
column from migration 0046) and assembles a compact block the snapshot
ships under `weeklyContext.glp1` — drug display name + generic active
ingredient, current dose + weeks-on-dose, chronological titration
history, weekly cadence (parsed from MedicationSchedule.daysOfWeek),
last + next injection date, last injection site, pen inventory math,
and side-effect tag counts from the last 14 days of MoodEntry rows.

The block is omitted entirely for users with no GLP-1 medications (the
helper short-circuits to null after one indexed Prisma lookup), so
web-only generic accounts pay zero token cost.

Marc directive 2026-05-14: the Coach must say "your Mounjaro 7.5 mg"
instead of "your medication" when the user has named the drug. The
snapshot is the load-bearing surface for that — the system-prompt
guardrail (GROUND RULE 9) lands in the follow-up commit and forbids
any dose-prescriptive use of this data.
2026-05-14 13:28:00 +02:00
Marc-André Bombeck a732b1926e feat(withings): subscribe to BP + temperature webhooks alongside weight
setupWebhook now subscribes to appli=1 (weight + composition),
appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2)
in one pass. The previous implementation only registered appli=1, so a
new BP reading from a BPM cuff sat for up to an hour waiting for the
hourly poll fallback instead of arriving via push within seconds.

A failure on one category is logged and we proceed with the rest —
losing one webhook is strictly better than rolling back all three.
The disconnect path mirrors the same iteration so cleanup unsubscribes
every category we ever subscribed to. Webhook handler already
dispatches `syncUserMeasurements` regardless of appli, so no change
needed on the receiver side.

Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the
unit test and the disconnect route stay in lockstep with setupWebhook.
2026-05-14 13:27:37 +02:00
Marc-André Bombeck 5cb4a1dde1 feat(doctor-report): per-section toggle UI + persistence 2026-05-14 13:24:35 +02:00
Marc-André Bombeck b1a3d59918 feat(withings): ingest meastype 123 (VO2 max) into VO2_MAX
The ScanWatch family reports VO2 max under meastype 123 in
mL/(kg·min), which matches HealthLog's canonical DB unit verbatim
(no conversion needed beyond the standard value × 10^unit exponent
decode). Targets the VO2_MAX enum slot added in v1.4.23 for the
Apple Health passthrough.
2026-05-14 13:24:25 +02:00