Commit Graph
810 Commits
Author SHA1 Message Date
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
Marc-André Bombeck ae8e2d77aa feat(withings): ingest meastype 12 (legacy Thermo temperature) into BODY_TEMPERATURE
First-gen Withings Thermo (WBT01) reports body temperature under
meastype 12. The current-gen Thermo ships meastype 71 (next commit).
Both decode through the same `value * 10^unit` path the client already
applies, and target the `BODY_TEMPERATURE` enum slot we added in v1.4.23
for the Apple Health passthrough.

Adds `src/lib/withings/__tests__/client.test.ts` with a synthetic
`getmeas` payload covering the exponent decode, unknown-meastype skip,
and two-decimal rounding. Re-exports MEASURE_TYPE_MAP so the test can
assert the mapping directly without importing internals.
2026-05-14 13:23:01 +02:00
Marc-André Bombeck cbcc0598b2 feat(schema): MedicationCategory + MedicationDoseChange + InjectionSite (migration 0046)
Phase 1 of W4d GLP-1 full integration. Forward-only additive migration: a
new `medication_category` enum (GENERIC | GLP1) lands as
`medications.treatment_class` (defaults to GENERIC so existing rows render
exactly as before). `medications.doses_per_unit` lets pen/vial-tracked
meds carry their dose count. `medication_intake_events.injection_site`
captures the 8-zone body-map pick — nullable; non-GLP-1 meds never set
it. `medication_dose_changes` holds titration history (Mounjaro 2.5 → 5
→ 7.5 → 10 → 12.5 → 15 mg per Eli Lilly's PI). `medication_inventory_events`
is the append-only ledger backing the low-stock dashboard caption.

Naming nuance: the new column is `treatmentClass`, not `category`, because
the existing `medication_categories` side-table already owns the
`category` slot in the API response (clinical taxonomy:
BLOOD_PRESSURE / VITAMIN / ...). Two orthogonal concepts, two columns.
2026-05-14 13:22:47 +02:00
Marc-André Bombeck c2fc95f815 fix(settings): dashboard section save actually persists
The Settings → Dashboard sub-section's Save button silently 422'd once
the user had touched any per-chart overlay popover. Root cause was a
Zod v4 semantics change: `z.record(z.enum(KEYS), ...)` now requires
every enum key to be present in the input, where Zod v3 treated the
same shape as a partial record. Real-world Save payloads only carry
the chart keys the user actually toggled, so every Settings → Dashboard
Save attempt with one or two `chartOverlayPrefs` entries failed with
`expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
the toast surfaced "Layout konnte nicht gespeichert werden".

Fix uses `z.partialRecord(z.enum(KEYS), ...)` which preserves the
original v1.4.18 intent — per-chart opt-in overlay prefs, missing keys
filled by the resolver. The inner object now also accepts the
`comparisonBaseline` field so a Settings save preserves the per-chart
comparison toggle the user set via the chart-card popover (otherwise
Zod's default strip behaviour wiped it on every save).

New integration suite pins:
  - partial chartOverlayPrefs round-trips
  - per-chart comparisonBaseline survives a Settings save
  - empty chartOverlayPrefs still persists (fresh accounts)
  - widget toggle changes land in the DB
2026-05-14 13:22:07 +02:00
Marc-André Bombeck d94981e010 chore(withings): document full meastype mapping + API coverage audit
Adds src/lib/withings/mapping.md as the single source of truth for the
meastype → MeasurementType table that the client.ts MEASURE_TYPE_MAP
mirrors, and .planning/research/withings-api-coverage.md as the full
audit of the Withings public API surface (Measure, Sleep, Heart,
Activity, Workouts, User, Notify) against HealthLog's ingestion.

The audit scores each gap on user-value / effort / schema-impact and
slots additions into v1.4.25 (cheap mappings), v1.4.26 (sleep + activity
sync, body composition expansion) and v1.5 (ECG, AFib, workouts).
2026-05-14 13:21:28 +02:00
Marc-André Bombeck 4c9c0687b5 style(coach): drop redundant per-row source labels in evidence disclosure
Each row in "What I'm looking at" / "Worauf bezieht sich das?" was
prefixed with `kv.label` — the same framing the disclosure heading
already supplies. The label is gone; rows lead with the value + unit
and keep the window as a parenthetical tail.
2026-05-14 13:19:49 +02:00
Marc-André Bombeck 1895fd3cd8 fix(coach): suggested-prompt chips show user message before thinking placeholder
The send hook now surfaces the user's message as an optimistic bubble
the moment send fires, so the chronological render order matches the
user's mental model: user message → "Thinking…" placeholder →
assistant reply. The optimistic copy drops as soon as the persisted
twin lands via the invalidate-refetch the SSE `done` frame triggers.
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 7c06b89717 feat(schema): User.doctorReportPrefsJson + migration 0045 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 58bbb4ec78 docs(planning): record W7b timezone follow-up report 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 16fbdbcb87 style: prettier across W7b surfaces 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 85c3def94f feat(coach): snapshot anchors yesterday/this-week to user displayTimezone
Up to v1.4.24 the snapshot's day-key and weekday labels ran through
utcDayKey() / utcWeekday() / isoWeekKey(), so a 23:50 Pacific/Auckland
reading was filed under the next UTC day and the Coach replied with
the wrong calendar. The pipeline now reads User.timezone (alongside
the existing coachPrefsJson read so the read budget is unchanged) and
threads it through every bucketing helper (dailyMeans, bucketWeekly,
buildDailyValueRows, buildDailyBpRows) plus the medication-compliance
day-roll-up and the Apple Health additive blocks.

The prompt-budget calculator (src/lib/ai/coach/budget.ts) is unaffected.
The ledger still uses UTC midnight for spend roll-over because the
operator's LLM bill cycles on UTC; only the SNAPSHOT itself shifts to
the user's calendar. The provenance counts are row counts, not day
counts, so no provenance drift either.

Integration test pins the boundary: a 13:00 UTC May 14 reading (=
01:00 NZST May 15) appears under "2026-05-15" in the Coach snapshot
for a Pacific/Auckland user. The same instant in Berlin would land on
May 14; the test asserts the snapshot does NOT bucket there.

Existing 88-test coach unit suite stays green (the mocked user row
returns no timezone, so the snapshot falls back to Europe/Berlin and
the v1.4.24 byte-shape holds).
2026-05-14 13:19:17 +02:00
Marc-André Bombeck ca7673358e docs(planning): W4 v1.4.25 insights sub-pages phase report 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 9af94d827f chore(openapi): regenerate docs/api/openapi.yaml
The W7 timezone agent committed an openapi/routes.ts change without
running pnpm openapi:generate. This regeneration brings the committed
yaml back in sync (cosmetic whitespace normalisation on multi-line
descriptions — no schema changes from this run).
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 1de3619fa4 i18n(insights): EN + DE strings for the 7 insights sub-pages
Adds the labels the v1.4.25 W4 sub-pages reference:

  - insights.navOverview / insights.navSleep — two new tab pills on
    the strip ("Overview" + "Sleep" / "Übersicht" + "Schlaf").
  - insights.subPage.{blutdruck,gewicht,puls,stimmung,medikamente,bmi}
    Description — one-line scaffold under each sub-page heading,
    matching the Apple Health metric-page convention.
  - insights.subPage.stimmungEmpty{Title,Description,Action} +
    medikamenteEmpty{Title,Description,Action} — CTAs into /mood and
    /medications when the corresponding sub-page has zero data.
  - insights.sleep.* — title, description, headline caption, stage
    labels (deep/REM/core/asleep/awake/inBed), composition title +
    subtitle + ARIA label, plus an empty-state group pointing at
    /settings/devices for the v1.5 Apple Health sync teaser.

i18n-locale-integrity.test.ts keeps the EN + DE key set in lock-step;
no changes there because the new keys land identically in both files.
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 84b54d9f11 refactor(insights): mother page hosts overview only; metric depth moves to sub-pages
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.

What's gone from the page (now lives on the matching sub-page):
  - Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
    pulse/mood/meds/bmi) with their target-zone + value-band wiring.
  - Per-metric status-card queries (blood-pressure-status,
    weight-status, pulse-status, bmi-status, mood-status,
    medication-compliance-status).
  - All the per-section <InsightStatusCard> + scatter-correlation
    cards (BP × continuity, weight × BP, mood × BP, mood × weight).
  - The bulky helper functions (getBloodPressureSectionStatus,
    getWeightSectionStatus, …, classifyRangeValue) and their imports
    (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
  - The inline <InsightsSectionNav> mount — superseded by the strip
    that <InsightsLayoutShell> now mounts.

What stays here:
  - HeroStrip (greeting, weekly-report banner, Health Score panel).
  - DailyBriefing (the AI morning paragraph).
  - CorrelationRow (cross-metric hypotheses).
  - TrendsRow (BP / weight / mood mini-charts above each annotation).
  - InsightAdvisorCard (the rich recommendations grid).
  - CoachDrawer — mother-page-only, per Marc's directive.

The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.

trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
2026-05-14 13:07:33 +02:00
Marc-André Bombeck cb825ec67a feat(insights): new /insights/schlaf sub-page with sleep-stage chart
The v1.4.23 schema added the SleepStage enum + Measurement.sleepStage
column for HealthKit per-stage rows, but the UI never surfaced them.
This commit adds the missing surface.

Components:
  - <SleepDurationChart>: thin wrapper around <HealthChart> with
    chartKey="sleep" so the per-chart cog (trend / target / compare)
    works the same way as the dashboard charts.
  - <SleepStageStackedBar>: horizontal Recharts stacked bar covering
    the trailing-30-day stage distribution (DEEP / REM / CORE /
    ASLEEP / AWAKE / IN_BED). Uses the /api/analytics sleepStages
    payload that v1.4.23 W2 already exposes; no new endpoint.
  - <SleepOverview>: composition that arranges the headline
    "X h Y min per night" card, the stage-composition stacked bar,
    and the duration trend chart. Empty state when the user has zero
    sleep rows points at /settings/devices with a v1.5 Apple Health
    sync teaser.

The sub-page itself (src/app/insights/schlaf/page.tsx) is trivial —
<SubPageShell> + <SleepOverview>. All data-fetching + empty-state
handling stays inside the components so a future v1.4.26 refinement
can iterate without touching the route.

Tests: <SleepStageStackedBar> covers the i18n titles (EN + DE), the
ARIA label that announces the nights count, and resilience against
unknown stage keys.
2026-05-14 13:07:17 +02:00
Marc-André Bombeck 959adec899 feat(api): DELETE /api/measurements/by-external-ids for iOS deletion-sync
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).

Contract:
  - Body: { externalIds: string[] } with min 0, max 500 entries.
  - Auth: requireAuth() — session cookie or bearer.
  - Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
    failure — matches the ingest path's symmetric tolerance).
  - Over-cap: 422 with errorCode "measurement.delete.too_large" so
    the iOS client can surface a specific diagnostic.

Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.

OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
2026-05-14 13:07:05 +02:00
Marc-André Bombeck 57bd4453ba test(i18n): auto-discover locales + fallback-chain runtime guard
i18n-locale-integrity now auto-discovers every messages/<locale>.json
instead of hardcoding en + de. When W9e lands fr/es/it/pl, the parity +
no-empty + no-TODO + no-duplicate-keys checks extend automatically.

fallback-chain.test.tsx documents the three-level resolver contract
(active locale → EN → raw key). Level 2 stays as a skipped contract
note since the integrity test makes it unreachable at runtime — every
key is guaranteed present in every locale before CI accepts.
2026-05-14 13:07:00 +02:00
Marc-André Bombeck 38bbba110a feat(insights): scaffold 6 metric sub-pages with shared layout shell
Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).

The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.

Shared infrastructure:
  - src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
    + SUB_PAGE_SLUGS canonical order.
  - src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
    layout payload so every sub-page picks up the comparisonBaseline
    without re-fetching.
  - src/components/insights/sub-page-shell.tsx: title + optional badge
    + focusable h1 (a11y — screen-reader focus restoration on tab
    navigation).
  - src/components/insights/insights-layout-shell.tsx: the layout's
    client shell that mounts the strip + threads the regenerate handler.

The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
2026-05-14 13:06:49 +02:00
Marc-André Bombeck 2668020d92 docs(contributing): translations section + community-maintained policy
Documents the EN/DE maintained tier vs FR/ES/IT/PL AI-initial community-
maintained tier. Includes the PR workflow for fixing an existing locale,
adding a new one, a translator style guide, and a forward reference to
the self-hosted Weblate plan tracked for v1.6.
2026-05-14 13:04:18 +02:00
Marc-André Bombeck eeaa563026 feat(charts): x-axis ticks render in user displayTimezone
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.

Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.

The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.

Tests: 109 chart-component tests stay green.
2026-05-14 13:04:12 +02:00
Marc-André Bombeck 095578fa57 refactor(admin): drop redundant collapse on single-purpose Login-Übersicht page
`/admin/login-overview` is the only card on its dedicated route since
v1.5 phase-4b. The Collapse/Expand toggle inherited from the v1.4
shared-admin page therefore hid the entire page contents on click,
serving no purpose beyond clutter. Drop the `expanded` state, the
header toggle button, and the `expanded && (…)` body wrapper; the
section always renders. The two queries lose their `enabled:
expanded` gate (a no-op now that they always run). The
`settings.collapse` / `settings.expand` i18n keys stay so future
surfaces can opt back in without re-introducing the strings.

A regression test mirrors the `api-token-no-collapse` pattern: no
"Collapse"/"Expand"/"Einklappen"/"Ausklappen" buttons, no
`aria-expanded` on the section heading, and the filter pills paint
unconditionally in SSR.
2026-05-14 12:57:38 +02:00
Marc-André Bombeck d87a6314e6 fix(admin): tidy CSV export — column order + escaping + ISO-with-offset
The legacy export emitted snake_case English headers and raw UTC
timestamps which Excel/LibreOffice misread once the `Z` suffix was
stripped (issue #167 territory) and forced admins to mentally map
`actor_username` → "Marc" every time they triaged a row. The export
now mirrors the on-screen column order — `timestamp → user → IP →
location → provider → outcome` — with `action` + `details`
appended so the full audit payload still rides along. Headers are
translated through the active locale; timestamps go through
`formatInUserTz(..., "iso-with-offset")` so they round-trip
correctly in any spreadsheet; `toCSV` now treats `\r` as an escape
trigger alongside `\n,"` so Windows-newline detail strings stay on
their row.

`buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in
`_shared.tsx` so the column-order/escape contract is pinned by a
pure unit test independent of the React render.
2026-05-14 12:54:26 +02:00
Marc-André Bombeck feeff38ba2 feat(coach-prompt): GROUND RULE 13 (insights) + 8 (coach) — no internal metric enum names in prose
Closes the loop on the "Metric Pressure_Sys" leak Marc reported on
the Einschätzungen surface 2026-05-14. The stripChartTokens regex
extension cleans up render-time output, but the model was free to
keep emitting raw enum identifiers turn after turn. This commit
teaches the generator to stop emitting them in the first place.

Insight-generator (DE + EN) gets GROUND RULE 13:

  - Lists every banned identifier verbatim — Pressure_Sys,
    BLOOD_PRESSURE_SYS, PULSE_BPM, MOOD_SCORE,
    MEDICATION_COMPLIANCE_PCT, plus the v1.4.23 Apple Health surface
    (HEART_RATE_VARIABILITY, RESTING_HEART_RATE,
    ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
    WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE,
    SLEEP_DURATION).
  - Enumerates the exact user-facing fields the ban applies to —
    summary, recommendations[].text, findings, dailyBriefing,
    trendAnnotations.*, weeklyReport.*, storyboardAnnotations.
  - Carves out the contract identifiers the parser reads
    (metricSource.type, sourceMetric, trendAnnotations keys) so the
    JSON contract stays intact — the ban applies ONLY to prose.
  - Documents the natural-language labels to use instead ("your
    systolic", "your weight", "your medication adherence", …).

Coach system-prompt (DE + EN) gets the matching GROUND RULE 8 with
the same banned list, the same prose-only carve-out, and an extra
clarification that evidence-block labels stay human-readable
("avg30 systolic", NOT "BLOOD_PRESSURE_SYS_AVG_30") while the
window slot keeps its documented contract vocabulary.

PROMPT_VERSION 4.23.0 → 4.24.0. The rule is genuinely new (neither
prompt previously mentioned the enum identifiers in any form), so
the bump reflects the additive behavioural change. Existing pin
assertions in coach-prompt-v423.test.ts + coach/system-prompt.test.ts
loosen from `/^4\.23\.\d+$/` to `4.{>=23}.x` so future additive
ratchets don't force a hostile test rewrite each time.

New unit tests pin GROUND RULE 13 + 8 by the banned-identifier
strings + the contract-carve-out wording so a future copy-paste
regression can't silently drop either rule.
2026-05-14 12:52:18 +02:00
Marc-André Bombeck 35f068ac26 feat(analytics): berlinDayKey callers honour user displayTimezone
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
2026-05-14 12:52:01 +02:00
Marc-André Bombeck b5062ead1c feat(admin): add Provider column to Login-Übersicht
The audit log records *how* each login happened in the action name
(`auth.login.passkey`, `auth.bearer.success`, …) but the table
collapsed all of that into a single Aktion label. Admins now see
the credential type as a separate column with a lucide glyph
(KeyRound / Fingerprint / Cpu / Globe) so password-vs-passkey-vs-API
token attempts are distinguishable at a glance — and the column
maps cleanly into the CSV export.

The mapping itself (`providerForAction`) lives in `_shared.tsx`
alongside `useAuthActionLabels` so any later audit-feed consumer
picks up the same i18n + icon contract; a regression test pins
every action → provider edge and every provider → icon edge.
2026-05-14 12:49:51 +02:00
Marc-André Bombeck a19768c5d4 feat(insights): plumb stripChartTokens through every AI-prose surface
The v1.4.22 stripper only ran on the InsightAdvisorCard summary,
findings, primaryRecommendation, and the RecommendationCard top-line
rec text. Every other AI-prose surface bypassed it entirely, so the
v1.4.25 W5b regex extension (capitalised Metric + orphan enum
patterns) silently could not help them.

Wire stripChartTokens() through every render path that pipes AI
output to the user:

- DailyBriefing — paragraph, finding.headline, finding.detail
- WeeklyReport — summary, goingWell[], worthWatching[], tips[],
  dataQualityNotes
- TrendAnnotation — annotation sentence
- RecommendationCard — rationale rows (dataWindow, comparedTo,
  deviation) inside the expanded RationaleCard
- InsightsCardPreview — dashboard mini-card rec text
- Coach message-thread — assistant bubble content (streaming +
  persisted). User bubbles stay untouched on purpose: their content
  is user-typed, not AI-emitted.

User bubbles, evidence-block key/value rows (constrained to the
sentinel grammar by the Coach prompt), and the deterministic
server-generated correlation interpretations stay raw — the leak
surface is AI prose, not those structured fields.
2026-05-14 12:47:40 +02:00
Marc-André Bombeck 6d6f4c4efb fix(admin): restore Standort column on Login-Übersicht
The Standort column existed but rendered right-aligned next to a
right-aligned IP and timestamp, which pushed it into the gutter on
wide audit tables and let admins overlook it entirely. Left-align
the IP and Location cells (timestamp keeps the right edge as the
sort anchor) and prefix the Standort header with a MapPin glyph so
the column is a first-class entry instead of a forgotten trailer.
2026-05-14 12:46:59 +02:00
Marc-André Bombeck ebde50d1c5 docs(planning): W3e v1.4.25 zielwerte redesign phase report
Captures the 9-phase implementation:
- per-phase commit SHAs (SHA → subject table)
- files added / changed / removed with line-count summary
- skills invoked (frontend-design, mobile-first-design, mcp__shadcn)
  + how each shaped the result
- aiEnabled gate implementation walkthrough so reviewers can verify
  the Coach CTA gate
- verification results (typecheck / lint / test / format)
- 6 open questions for Marc (PROMPT_VERSION, streak thresholds, met-
  this-week threshold, MOOD_STABILITY pill redundancy, prefill i18n
  decision, deferred cog-edit affordance)
- 4 deferred items with rationale
2026-05-14 12:45:09 +02:00
Marc-André Bombeck 5deed3f92c fix(insights): stripChartTokens covers capitalised Metric + orphan enum patterns + Apple Health additives
Marc reported a raw "Metric Pressure_Sys" token leak on /insights
2026-05-14. The v1.4.22 stripper only matched the canonical
`metric:<TYPE>` colon form; the model emitted a capitalised "Metric"
prefix followed by a Pascal/snake-cased identifier and the substring
sailed through to the rendered prose unchanged.

Extend `stripChartTokens()` with two additional matchers:

- `Metric <Word>` capitalised two-word phrase (catches the exact
  pattern Marc saw, plus PascalCase variants and the v1.4.23
  Apple-Health additions like `HEART_RATE_VARIABILITY`).
- Fixed-list orphan upper-snake-case enum identifiers
  (`BLOOD_PRESSURE_SYS`, `PULSE_BPM`, `MOOD_SCORE`,
  `MEDICATION_COMPLIANCE_PCT`, plus the v1.4.23 Apple Health enum
  surface: `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
  `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
  `WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`,
  `SLEEP_DURATION`).

The orphan-enum list is fixed (not a generic `[A-Z_]+`) so single
capitalised words like `WEIGHT` or `MOOD` stay legible in normal
prose — only the suffixed/database-style enum names get cleaved.
Lowercase "metric" inside legitimate English prose ("Each metric
tells part of the story") is also left untouched.

Tests: 14 new cases covering every documented leak pattern plus two
defence-in-depth assertions (combined leaks; German "Metrik" safe).
2026-05-14 12:44:47 +02:00
Marc-André Bombeck 5d3a4c0b13 style(settings): widen Doctor-Report export card to full-row on md+
Doctor-report is the most user-facing export — give it its own row above
the 4-card 2×2 grid so it reads as the primary action. Mobile keeps the
single-column stack since every card is already full-width there.
2026-05-14 12:43:17 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:

  • targets.summary.{weekTitle,weekTitleAll,streakHighlight}
  • targets.card.{lastMet,streak}
  • targets.consistency.{ariaLabel,daysInRange,daysLogged,
                          dotInRange,dotNearRange,dotOutOfRange,
                          dotNotLogged,insufficientData,relativeDay.*}
  • targets.relativeDay.{today,yesterday,daysAgo}
  • targets.coach.cta
  • targets.mood.stability.{stable,variable,highlyVariable}

Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck ec4fb9837a feat(targets): mount CoachDrawer on /targets with per-card scope prefill
The drawer mount + per-card CTA handoff was wired during Phase 5
when the page.tsx was rewritten around the extracted primitives.
This commit lands the integration tests + prompt-builder tests that
pin the contract:

  • targets-coach-mount.test.tsx — three branches:
      1. AI provider configured → per-card "Ask Coach" CTAs render
      2. No AI provider → CTAs hide entirely (no broken-button state)
      3. Chain query still loading → same as #2 (don't render a CTA
         whose click could 404)

  • target-prompts.test.ts — six cases:
      1. EN BP template threads current value + range + cadence + streak
      2. DE BP template threads the same values with German copy
      3. Streak fragment is omitted when streakDays < 3
      4. MOOD_STABILITY uses the verbal label inline ("stable" /
         "variable" / "highly variable") not the raw σ
      5. Unknown target types fall back to a general EN template
      6. DE fallback localises the question shape

  • coachScopeForTarget — four cases:
      1. WEIGHT + BMI map to ["weight"]
      2. BP + BP-in-target map to ["bp"]
      3. MOOD_SCORE + MOOD_STABILITY map to ["mood"]
      4. Unmapped targets return [] so the drawer falls back to defaults

PROMPT_VERSION stays at 4.23.0 per Marc's prompt-version rules:
the assistant-facing system prompt does not change; only the user-
side prefill does. Confirm in the W3e reconcile if a future change
makes a prefill load-bearing on the model's behaviour.
2026-05-14 12:37:46 +02:00
Marc-André Bombeck f223b4feae feat(targets): mobile-first responsive grid + card reflow
Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:

  • default (<640px): grid-cols-1, full-width cards, gap-4. The
    per-card Coach CTA spans full width (justify-center) so the
    touch target is generous; the source link sits beneath
    right-aligned.
  • sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
    horizontal — Coach CTA left, source link right.
  • lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
    the dashboard / insights rhythm and lets the user see all six
    core targets above the fold on a 1440px viewport.

Card internals also reflow:
  • Headline row: icon + label stack with status pill below on
    mobile (sm:flex-row sm:justify-between), inline on sm+.
  • Footer row: flex-col items-stretch on mobile,
    sm:flex-row sm:items-center sm:justify-between on sm+.

New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.

Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
2026-05-14 12:36:21 +02:00
Marc-André Bombeck 9acee2bd81 feat(targets): MOOD_STABILITY headline switches from σ to verbal label
The card used to render the raw σ value ("0.42 σ") as the big headline
number. Most users cannot interpret a standard deviation; the W3e
research found this was the single biggest "what does this mean?"
friction point in the v1.4.22 page.

Replace with a three-state verbal label keyed by the
`moodStabilityLabel(sigma)` helper landed in the previous phase
(thresholds: < 1.0 stable, < 2.0 variable, ≥ 2.0 highly variable).
Translations under `targets.mood.stability.{stable,variable,highlyVariable}`
in EN + DE.

The raw σ value stays one hover away — the headline carries a
tooltip surfacing "σ = 0.42" so power users keep the precise number
without forcing every visitor to decode it. Headline also gets a
`capitalize` class so the lowercase i18n string ("stable" / "stabil")
renders sentence-cased.
2026-05-14 12:34:47 +02:00
Marc-André Bombeck f2b9f843b1 feat(targets): card layout with status pill + consistency + coach handoff
Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:

- TanStack-Query pulls the redesigned targets payload (with the
  v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
  `aiEnabled = chainStatus?.activeProvider != null`. The flag threads
  down into every <TargetCard>, which uses it to gate the per-card
  Coach CTA — no broken-button state when the user has no provider
  configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
  owns the drawer's open + prefill + scope triple. Per-card buttons
  call `askCoach()`; the hook flips state and the drawer mounts
  inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
  PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
  directive: stable visual hierarchy beats status-sorting because
  the user's eye lands on the same metric in the same place every
  visit.
- Source-citation URLs stay on the page (declarative routing) and
  thread into <TargetCard> via the `sourceLink` prop.

The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.

Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
  lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
2026-05-14 12:33:53 +02:00
Marc-André Bombeck 96c5e79539 feat(targets): page-level summary header
Adds a one-line summary above the card grid that surfaces the
account-level state at a glance:

  "4 of 6 targets met this week"  /  "All 6 targets met this week ✓"

When every target hits the bar we surface a calm green check mark; no
animation, no celebration. Per Marc's "muss nicht so sein wie die KI
Sache" — the summary stays scannable and quieter than the v1.4.20
Insights hero strip.

A streak highlight chip ("5-day streak: Blood pressure") renders to
the right when one metric carries a current ≥ 3 day streak. Resolves
via the existing targets.label.{TYPE} i18n map so the metric name
respects the user's locale.

The new <TargetsSummaryHeader> primitive lives under
src/components/targets/ alongside the rest of the W3e extraction.
4 tests cover partial / all-met / streak / empty branches.
2026-05-14 12:30:18 +02:00
Marc-André Bombeck 97583cf891 refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton
The /targets page composed everything inline in a 790-line page.tsx.
Break it into focused primitives under src/components/targets/ so the
W3e redesign can iterate per-card without growing the page file:

- target-card.tsx — the redesigned card composition. Headline + verbal
  status pill, big-number row with inline trend chevron, range bar,
  consistency strip, recency + streak chips (both conditional on real
  data), per-card Coach CTA gated by aiEnabled, source link footer.
  MOOD_STABILITY swaps the raw σ headline for a verbal label via the
  moodStabilityLabel helper. Fixed card order is owned by the page;
  this component is presentational.
- range-bar.tsx — extracted intact from page.tsx. Same behaviour, same
  data-slot. Now carries its own test suite.
- consistency-strip.tsx — NEW. 7 dots (one per Berlin-tz day), each
  filled / amber / red / hollow per the day's band classification.
  Caption flips between "X of 7 in range" and "X of 7 logged" when
  cadence is the more informative signal. Accessible: <ul role="list">
  with per-dot aria-labels seeded from i18n. No empty-state copy
  inside the strip — Marc directive: hide entirely when insufficient.
- target-coach-button.tsx — NEW. Per-card "Ask Coach about this" CTA
  that returns null when aiEnabled is false. No greyed-out button,
  no tooltip placeholder — the gate is total per Marc's
  "no broken-button state" directive.

Supporting helpers (each unit-tested):
- src/lib/targets/mood-stability-label.ts — pure σ → verbal-label
  threshold logic (< 1.0 stable, < 2.0 variable, ≥ 2.0
  highlyVariable). Thresholds documented in the helper and confirmed
  in the W3e plan.
- src/lib/ai/coach/target-prompts.ts — 8 metric × 2 locale templates
  for the per-card Coach prefill. Falls back to a general template for
  glucose contexts. PROMPT_VERSION unchanged (the assistant-facing
  prompt does not shift; only user-side prefills do).
- src/lib/ai/coach/target-scope.ts — per-target CoachScope.sources
  mapping. Derived metrics (BMI → weight, MOOD_STABILITY → mood) point
  at the underlying source; unmapped targets return an empty array
  so the drawer falls back to defaults.

i18n keys added: targets.consistency.* (cap + dot aria + relative day
+ insufficient-data), targets.card.{lastMet,streak}, targets.relativeDay.*,
targets.summary.{weekTitle,weekTitleAll,streakHighlight},
targets.coach.cta, targets.mood.stability.* — all EN + DE.

Tests: 17 cases across 5 suites (consistency-strip render variants,
target-coach-button gate branches, range-bar zone painting,
target-card full-data + insufficient-data + aiEnabled-off +
MOOD_STABILITY verbal-headline, mood-stability-label thresholds).

Page.tsx still uses the old inline composition for now — Phase 5
wires it to the new components.
2026-05-14 12:29:02 +02:00
Marc-André Bombeck f3c9e2d7a6 feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:

- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
  week. The client renders 7 dots, one per day, filled when the day's
  mean reading landed in the green band, hollow when logged but
  out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
  reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
  page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
  target band, or null when no in-range day in the last 30. Surfaces
  the maintainer-tester case the W5 reconcile flagged (today lands
  in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
  OR < 1 day logged in 7d. The client hides the strip + percentage
  when set so we never surface a misleading "0 of 7" caption.

Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.

Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).

Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
  mood) — classify the day's mean against the same green band the
  RangeBar paints. Orange band reuses the pulse / glucose
  configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
  every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
  ≥ 50% = near, otherwise out. Days with zero scheduled doses
  return null so they don't break the streak.
2026-05-14 12:21:25 +02:00