Commit Graph
877 Commits
Author SHA1 Message Date
Marc-André Bombeck cf5d6c433a docs(workout): TODO for cross-source workout dedup at v1.5 iOS landing
The Workout uniqueness key is (userId, source, externalId). The same
workout ingested from MANUAL plus the iOS HealthKit passthrough lands
two rows because `source` differs — Postgres NULL-distinct semantics
keep manual workouts unique among themselves, but the cross-source
case has no canonical-picker layer yet.

Measurements already solved the symmetric problem via the W8c
canonical-source picker. Workouts need the same shape before the
dashboard "recent workouts" tile can show one row per workout instead
of one per source. Schema-only release today (W8d), and there is no
iOS client yet, so the smell is latent — track it inline on the model
so the v1.5 iOS sprint picks it up.
2026-05-14 16:52:52 +02:00
Marc-André Bombeck 2bb49ae764 chore(source-priority): observable parse failures + frozen resolved blob
Two reconcile fixes on parseSourcePriority(), one observability and one
invariant guard:

  - Emit a wide-event tag when the persisted JSON fails the Zod schema
    so a future schema-tightening regression that silently nukes a
    user's saved ladder surfaces in ops dashboards. annotate() is a
    no-op outside a request context, so static settings-page renders
    and unit tests stay side-effect-free.

  - Deep-freeze the resolved blob. The top-level flat keys share their
    backing arrays with `metricPriority` via the spread, so a caller
    mutating `resolved.metricPriority.weight = […]` would silently
    desync the two views. Freezing every ladder + the containers + the
    resolved root surfaces the mutation at runtime in strict mode.
2026-05-14 16:49:49 +02:00
Marc-André Bombeck 71745b4c63 fix(analytics): thread user timezone through the weight-weekday correlator
The W7 per-user-tz sweep threaded `userTz` through every aggregator on
the analytics route except `berlinIsoWeekday()` — the module-level
`Intl.DateTimeFormat` stayed pinned to `Europe/Berlin`. A user in
`Pacific/Auckland` whose 23:30 weight reading lands at 12:30 the same
day in Berlin had their Monday-local reading bucketed under Sunday's
weekday column. Pearson/ANOVA on the wrong weekday.

Replace the pinned formatter with a per-tz memoised lookup, rename the
helper to `isoWeekdayInTz()`, and feed `userTz` through the call site.
Also rename the misleading `dateFromBerlinKey()` → `dateFromDayKey()`
since the keys it consumes are already anchored to the user's tz
upstream.

Integration test covers the non-Berlin path: 28 weight readings at a
UTC instant where the Berlin and Auckland weekdays disagree; the
correlator reaches the gate with every seeded row counted, confirming
the helper used the user's tz to bucket.
2026-05-14 16:48:46 +02:00
Marc-André Bombeck e14466e238 fix(i18n): sleep headline suffix translates across all six locales
The 'per night' caption beneath the sleep headline was branching on
`locale === "de"` and falling through to English for FR/IT/ES/PL.
Move the string into `insights.sleep.headlineCaptionSuffix` with
native translations (de: pro Nacht, fr: par nuit, it: per notte,
es: por noche, pl: na noc) and resolve it through `t()`.
2026-05-14 16:47:06 +02:00
Marc-André Bombeck c38a2c8a2a fix(auth): allow narrow-scope tokens on unscoped routes — unblocks v1.5 iOS endpoints
The previous `requireAuth()` policy 403'd every narrow-scope Bearer token
on routes that did not declare a required permission. The intent was to
keep wildcard tokens from acting as session-equivalents on sensitive
routes that never gated on scope, but the side effect blocked four
W8d routes (`/api/measurements/by-external-ids`, `/api/personal-records`,
`/api/medications/[id]/glp1`, `/api/dashboard/glp1`) for the v1.5 iOS app.

Restate the contract: a route that does not declare a `requiredPermission`
is content with any authenticated token; a route that declares one gates
on it. Narrow-scope tokens still 403 against scoped routes they don't
list. Add four cases to the Bearer-auth test suite covering all token-
× route-scope combinations.
2026-05-14 16:46:35 +02:00
Marc-André Bombeck db5e07a944 fix(source-priority): resolve device-type ladder per row in mixed-type buckets
The W8c picker pinned the device-type ladder to `pickedRows[0].type` for
the entire bucket and walked one ladder for every row. The inline
comment promised per-row resolution against the cached ladder map; the
implementation never delivered it. Today's only caller — the
`SLEEP_DURATION` aggregation in `/api/analytics` — feeds single-type
rows so the gap is latent. Future callers (Coach evidence rollup,
doctor-PDF section, correlations engine) that batch multiple
MeasurementTypes through one picker call would have tested non-matching
rows against the wrong ladder.

Group present device-types per row-type, resolve each type's ladder
from the cache, pick the winning device-type independently per type.
Per-type fall-through covers the case where the user's custom ladder
doesn't enumerate any device-type present for a given row-type.

Cover the multi-type path with two new tests:
  - per-row ladder resolution preserves each type's winner
  - per-type fall-through when a custom ladder is unreachable
2026-05-14 16:46:03 +02:00
Marc-André Bombeck bd1cb2c109 fix(insights): sleep-stage chart strokes resolve through Dracula tokens
CartesianGrid and axis labels were rendering un-themed because
`stroke="hsl(var(--border))"` evaluates to `hsl(#44475a)` —
invalid CSS — and the SVG fell back to currentColor. The Dracula
tokens are already hex, so the bare `var(--border)` /
`var(--muted-foreground)` form is the correct contract.
2026-05-14 16:45:08 +02:00
Marc-André Bombeck 2fd31f20b9 style(health-score): drop double divider above provenance accordion
The components list above the accordion already paints a top
border; a second border on the accordion wrapper made the divider
stride read visually heavy. Drop it so the accordion sits flush
under the four sub-bars.
2026-05-14 16:25:03 +02:00
Marc-André Bombeck 380c6cecc6 test(health-score): drift-guard for provenance i18n key coverage
Pins every key the W8e accordion renders to the locale-integrity
guard. Mirrors the measurement-list-meta enum-coverage pattern:
if a future regression drops a key or adds a new source token
without a matching locale entry, the test fails fast across all
six shipped locales.
2026-05-14 16:24:01 +02:00
Marc-André Bombeck ae700eddfd a11y(health-score): aria-labelledby pairing on provenance panel
Pairs the panel <section> with the toggle button via id +
aria-labelledby so screen readers announce 'Driven by' as the
panel's accessible name. The toggle already advertises
aria-expanded + aria-controls; the labelledby pairing closes the
loop the WAI accordion pattern recommends.

The drift-guard test that pinned strict attribute order on the
panel id now tolerates additional ARIA attributes between id and
data-slot via a flexible regex.
2026-05-14 16:22:57 +02:00
Marc-André Bombeck b48d05a8be feat(health-score): inline provenance accordion with source attribution
Adds a tap-to-expand 'Driven by' panel under the four component
sub-bars inside <HealthScoreCard>. Each row pairs the existing
value bar with a second weight-share bar tinted in the Coach
provenance accent plus a source pill (manual / withings /
appleHealth / mixed / none). Rows sort by effective weight
descending so the biggest contributor leads; null components sink
to the bottom and dim to 50 % opacity.

A 'Provisional' header badge fires when fewer than half of the
configured inputs carry data, and a role=status 'mixed sources'
banner surfaces above the rows whenever at least one component
blends multiple ingest paths.

i18n keys land under insights.healthScore.provenance.* across all
six locales (en / de / fr / es / it / pl with formal register).
The locale-integrity drift-guard auto-discovers the new keys.
14 new RTL-style SSR tests pin the contract: aria-expanded toggle,
aria-controls/id pairing, sort order, source-pill localisation,
mixed banner gating, empty-state dimming, provisional threshold,
asOf tooltip forwarding, and footnote rendering.
2026-05-14 16:21:32 +02:00
Marc-André Bombeck 86737fd6a5 feat(health-score): expose per-component source + asOf in provenance detail
Extends HealthScoreComponentDetail with a source pill (manual /
withings / appleHealth / mixed / none) and an asOf ISO timestamp.
The analytics route folds the persisted MeasurementSource enum from
weight + BP rows into the new attribution slot; mood + compliance
stay manual until later ingest paths land. Backward compat is
preserved — callers that don't pass attribution still get a valid
result with manual defaults for present components.
2026-05-14 16:15:18 +02:00
Marc-André Bombeck ed6fd46c5b docs(planning): W8d v1.4.25 AH-server-prep phase report
Captures the five-sub-task wave that locks the Apple Health server
contract before the v1.5 iOS-Swift session: migrations 0052 / 0053 /
0054, the mapping extension, the personal-records skeleton, and the
opt-in VO2 max tile. Restates the WORKOUT_ROUTE drop rationale and
lists the items deferred to v1.4.26+.
2026-05-14 16:07:50 +02:00
Marc-André Bombeck b324e6c41d feat(dashboard): VO2 max trend tile (opt-in, secondary-metric pattern)
Adds the VO2 max strip tile to /, matching the existing
secondary-metric convention (sleep / steps / glucose / oxygen
saturation are all default-invisible + opt-in via Settings →
Dashboard).

Wire-up:
  - src/lib/dashboard-layout.ts: vo2Max appended to
    DASHBOARD_WIDGET_IDS and DEFAULT_DASHBOARD_LAYOUT.widgets with
    visible=false / tileVisible=false. The /api/dashboard/widgets
    Zod enum is derived from the same array so the toggle is wired
    automatically.
  - src/app/page.tsx: vo2Summary from data?.summaries?.VO2_MAX
    (auto-populated by /api/analytics; the route iterates the full
    measurementTypeEnum.options list, no backend change needed),
    hasVo2 data-floor gate, showVo2Tile combined gate, and the
    TrendCard push next to showStepsTile. directionSentiment is
    up-good (higher VO2 max is better) and the unit string runs
    through the t() helper so the iOS app session can localise it.
  - src/components/settings/dashboard-layout-section.tsx: vo2Max
    label key added to WIDGET_LABEL_KEYS so the Settings → Dashboard
    toggle row renders the localized label.
  - messages/{en,de,fr,es,it,pl}.json: dashboard.vo2Max + vo2MaxUnit
    keys. "VO₂ max" is universal across all six locales (scientific
    abbreviation, no translation needed); the unit string is
    consistent SI notation.

No chart-row card — that's an explicit v1.5 deferral per the W8d
outline §6.4. The tile lights up the moment the user accumulates any
VO2 sample (Withings ingest or the upcoming iOS Health passthrough).
2026-05-14 16:04:46 +02:00
Marc-André Bombeck 8f57f5e0d7 feat(schema): PersonalRecord + direction helper (Migration 0054)
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.

Schema (prisma/migrations/0054_personal_record):
  - PersonalRecordDirection enum (MAX | MIN) stored on the row so
    the future detection worker doesn't re-resolve direction at
    query time.
  - personal_records table: userId FK (cascade), metricType
    (MeasurementType enum), nullable metricSlot for sport-specific
    PRs, direction NOT NULL, value + unit + achievedAt, optional
    sourceMeasurementId (SET NULL on delete so PRs survive the
    underlying measurement), source provenance, externalId.
  - Composite unique (userId, metricType, metricSlot, achievedAt)
    for idempotent worker re-runs.
  - Index (userId, metricType, value) for the "current PR" read
    pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).

Direction helper (src/lib/personal-records/pr-direction.ts):
  - getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
    switch covering all 28 canonical enum values:
      MAX — steps, active energy, flights, distance, VO2 max, HRV,
            total body water, bone mass, muscle mass, time in daylight.
      MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
            pulse-wave velocity, audio exposure (env + headphone).
      null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
             sleep duration, fat-free mass (homeostatic, clinical-edge,
             or direction-ambiguous metrics; the v1.4.26 worker can
             enrich weight from User.thresholdsJson when it ships).
  - isPRTrackable() convenience predicate for the future worker.

API surface (src/app/api/personal-records/route.ts):
  - GET returns { data: PersonalRecord[], error: null } (canonical
    apiSuccess envelope).
  - Optional ?metricType=X filter, validated against
    measurementTypeEnum; unknown values drop silently so a
    loose-typed caller never 400s.
  - No write endpoint, no detection worker — explicitly v1.4.26
    scope.

Tests (drift-guard + route):
  - pr-direction: every MeasurementType returns one of MAX / MIN /
    null; per-direction bucket membership pinned.
  - route: 401 unauth, empty response, valid filter passthrough,
    unknown-filter graceful drop, seeded-record round-trip.
2026-05-14 16:01:26 +02:00
Marc-André Bombeck 5d6f0c3110 feat(schema): Workout + WorkoutRoute tables for HKWorkout passthrough (Migration 0053)
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.

Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):

  - Typed columns for every hot-path metric (sport, duration, energy,
    distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
    shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
    getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
  - Open `metadata Json?` blob for the long tail (HK metadata bag,
    Withings HR-zone breakdown, device bundle id).
  - `sportType` is TEXT, not a Postgres enum — Apple alone carries
    70+ HKWorkoutActivityType values and the tail only grows. Adding
    a new sport is a Zod refinement, never a Postgres migration.
  - Composite unique `(userId, source, externalId)` for ingest dedup;
    Postgres NULL-distinct semantics keep manual entries non-colliding.
  - WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
    delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
    dependency — v1.4.20 set the same precedent for correlations).
    Per-sample timestamps + speed/HR ride in `sample_timestamps` and
    stay parallel to the coordinate array.

New module src/lib/validations/workout.ts:
  - workoutSportTypeEnum: 20-member roster covering ≥98% of typical
    workouts in the open-source corpora referenced in the outline.
  - geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
    component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
  - workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
  - createWorkoutSchema: full HKWorkout-shaped payload Zod for the
    eventual POST /api/measurements/batch?kind=workout endpoint.

13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
2026-05-14 15:57:39 +02:00
Marc-André Bombeck 89fb3bcc6c docs(planning): W9f hot-fixes addendum to W9e phase report
Records the two release-blockers resolved against the W9e translation
wave before tagging v1.4.25:

- Fix 1 (parseLocaleFromAcceptLanguage regression): no-op — the W7d
  agent flagged a stale failure that the W9e wave itself had already
  closed by adding a dedicated FR/ES/IT/PL prefix-match test block.
- Fix 2 (missing comparison.toggleHint): bundled into 6a7da4b which
  restored the key across all six locales alongside the audio-exposure
  / time-in-daylight enum backfill.

All quality gates clean — full suite 2583 passed (1 skipped), above
the post-W9e baseline of 2577.
2026-05-14 15:55:42 +02:00
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 6a7da4b2cb fix(i18n): backfill audio-exposure + time-in-daylight + comparison hint keys
W8d.1 follow-up — the first commit landed the schema + code surface but
the messages/*.json edits did not persist through the editor sync. This
commit completes the locale parity:

  - typeAudioExposureEnv / typeAudioExposureHeadphone / typeTimeInDaylight
    across en (translated), de (translated), and fr / es / it / pl
    (English placeholders matching the existing convention for the W5d
    additions; will be filled by the locale build script on the next
    pass)
  - comparison.toggleHint backfilled to it / pl which were missing the
    key the other four locales already carry — pre-existing drift that
    surfaced in the same diff, included here so the locale-integrity
    drift-guard test runs green

No code change. The drift-guard tests
(measurement-list-meta + i18n-locale-integrity) now pass.
2026-05-14 15:52:44 +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 5d5a934774 chore(i18n): translation-bundle build script + W9e phase report
scripts/i18n/build-locale.py walks messages/en.json and emits the four
AI-initial locale bundles (FR / ES / IT / PL) via a layered translation
strategy: full-path overrides for highly contextual strings, a
common-vocab lookup keyed on the lowercase EN value, placeholder
protection that splits {interpolation} tokens and <tag> markers out
before translation and re-glues them verbatim afterwards, and a
fallback to the English value for long-tail prose the script can't
cover. Re-running the script on a fresh en.json keeps the four
locale bundles in parity automatically — handy when new keys land
between releases.

The phase report (.planning/phase-W9e-v1425-translations-report.md)
captures the per-locale vocabulary calls (Apple-Health / Withings
register checks for each language), the system-prompt routing
decision (EN body + REPLY LANGUAGE footer rather than four native
~500-line bodies, to preserve every safety contract verbatim), and
the deferred polish for v1.4.26 (hand-review prose for the highest-
traffic surfaces, native FR/ES/IT/PL prefs strings in
buildPrefsPrefix, locale-native date formats).
2026-05-14 15:40:51 +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 d72c60c098 docs(planning): v1.4.25 session handoff document
Previous Claude session reached 79% context. Handoff for the new
session to pick up W8 → W13 cleanly without re-reading the full
phase-report trail. Lists exactly what's landed (all 23 waves
inventoried), what's still open (W8/W9e/W10/W11/W12/W13), the
critical Marc-Voice + branch-model + i18n conventions, the
in-flight test failures to expect, and the verbatim greeting for
the new session.
2026-05-14 14:22:17 +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 6f04123982 docs(planning): W3f v1.4.25 four polish wins phase report
Records the four polish-bar deliverables (per-card target-edit cog,
comparison-overlay grey-out, orphan general-status endpoint cleanup,
per-night sleep-stages stacked bar) with their commit SHAs,
file lists, verification results, and notes on the parallel-agent
commit-message collision that swept Win 1 into d74aa20.
2026-05-14 14:12:15 +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