Commit Graph
848 Commits
Author SHA1 Message Date
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
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 8b79ed89c8 chore(schema): medication_schedules @map to snake_case (cosmetic + convention)
The `windowStart` / `windowEnd` columns on `medication_schedules` were
the last hold-outs using the camelCase identifier from 0001_init.sql;
every other column on the table already follows the snake_case
convention. Adding `@map("window_start")` / `@map("window_end")` aligns
the SQL identifier without changing the Prisma client surface — every
read/write site continues to use the TypeScript-side `windowStart` /
`windowEnd` names verbatim.

Migration 0047 renames the columns in place — no data movement, no
index churn, no FK changes. Forward-only.
2026-05-14 13:50:38 +02:00
Marc-André Bombeck 044f971133 docs(planning): v1.4.25 W9 repo polish phase report 2026-05-14 13:50:29 +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 ef03705e51 docs: rewrite README hero — what / who / try the demo (30-second read)
Adds a 'What it is' section between the badges and the existing 'Why
HealthLog?' paragraph so a stranger landing on the repo sees the
product proposition in under 30 seconds without scrolling. The section
gives a two-paragraph product description and a direct CTA to the
live demo, addressing the marketing-checklist gap.

Also reframes the Quick Start lead from 'Plan ~5 minutes' to
'3 minutes from git clone to a working install' so the install-time
promise lives next to the install instructions.
2026-05-14 13:48:38 +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 d6315026b4 docs(planning): record W5 v1.4.25 Coach drawer polish phase report 2026-05-14 13:38:54 +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 d51e488739 docs(planning): W6c v1.4.25 doctor-report toggles phase report 2026-05-14 13:34:09 +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
Marc-André Bombeck 4158a02966 feat(withings): ingest meastype 35 (legacy SpO2) into OXYGEN_SATURATION
Older Withings firmware and some partner SDK examples report SpO2
under meastype 35 instead of 54. Mapping both into OXYGEN_SATURATION
prevents a quiet drop on legacy devices and is compatible with mixed
fleets (the existing meastype 54 path stays in place).
2026-05-14 13:24:01 +02:00
Marc-André Bombeck fb23796d72 feat(withings): ingest meastype 71 (current-gen Thermo) into BODY_TEMPERATURE
Current-gen Withings Thermo and ScanWatch series report core body
temperature under meastype 71 (the legacy meastype 12 stays in place
for first-gen Thermo). Both decode through the same value × 10^unit
path and target the BODY_TEMPERATURE enum slot.

Skin temperature (meastype 73) stays out until v1.4.26 ships a
SKIN_TEMPERATURE enum — surface temps (~32 °C) and core temps (~37 °C)
must not share a rollup or correlation analytics will collapse.
2026-05-14 13:23:33 +02:00
Marc-André Bombeck 861fe7ad0d test(charts): comparisonBaseline data correctness end-to-end
Pins the comparison-overlay shift across the edges Marc flagged worry
about. The existing `shiftDailySeriesForward` implementation is correct
— integer-day forward addition, no DST surprises because every input
timestamp is anchored at UTC-noon (well clear of the 02:00-03:00 wall-
clock jump). No production fix needed; this is a regression-floor
suite so a future refactor cannot silently change the contract.

Pinned scenarios:
  - DST forward shift (Mar 31 2024 Berlin spring-forward) — calendar
    day preserved
  - DST backward shift (Oct 27 2024 Berlin fall-back) — calendar day
    preserved
  - Leap-year alignment — Feb 29 2024 + 365 days lands on Feb 28 2025
    (the contract is "365 days ago", not "same calendar date last
    year")
  - Insufficient prior data (60 days history + `lastYear` request) →
    overlay renders empty so the chart drops the comparison cleanly
    without rendering a "kein Vergleich" placeholder (per Marc directive
    2026-05-14)
  - Partial prior-period coverage flags `hasComparisonData = true`
  - Tile-caption math (`averageValue` + `computeComparisonDelta`)
    returns null cleanly when the prior window is empty
2026-05-14 13:23:13 +02:00