Three small surface fixes consolidated because each is a one-touch
change on a separate file:
- `ResearchModeAcknowledgmentDialog`: the entire `DialogContent`
scrolled at `max-h-[90vh] overflow-y-auto`, which pushed the
Acknowledge / Cancel CTAs below the fold on phone-class viewports.
Move scrolling onto the body section only so the header and footer
stay pinned.
- Medication history page back-button rendered the literal "Zurück"
instead of going through `t()`. Wire the new `medications.back`
key across all six locale bundles.
- `GoalsChipPicker` shipped a localStorage persistence block scoped
by user id, but no consumer reads the same key. Drop the storage
layer (and the unused `goalsStorageKey` /
`ONBOARDING_GOALS_STORAGE_PREFIX` exports) until v1.4.26 lands the
`User.onboardingGoals` column the picker is meant to feed.
Adds the read-only TitrationSection to the medication detail page,
mounted between SchedulingSection (W19e) and IntakeHistoryList.
Same chrome as the W19d / W19e panels so the Wave-4b stack reads
as one visual group.
Layout:
- Header with drug INN
- Vertical step list on mobile, horizontal track from sm; each
step shows dose, typical-weeks badge, and a current/past/upcoming
state attribute the design system can hook (CSS-only, no JS state)
- Current step highlighted with a primary-color ring + 'You are
here' caption
- Optional dwell hint + next-step caption — observational copy
only (no 'should/must step up' anywhere; verified by test)
- Ceiling message when the user is on the top step
- Non-standard dose caption when the latest dose doesn't snap to
a step inside the ±10 % tolerance
- Disclaimer + external EMA-source link on every render
i18n (medications.titration.*) added in all six locales after the
medications.scheduling block per the W19e handoff for stable JSON
ordering. EN + DE hand-curated Marc-Voice; FR / ES / IT / PL drafted
from EN. Parity test (i18n-locale-integrity) passes.
10 component tests cover surface render, step-card count, single
'you are here', next-step caption, ceiling message, non-standard
dose, disclaimer + EMA link, empty ladder, observational-copy
discipline, and DE locale switch.
The escalationDueHint copy is strictly reference (MDR boundary,
parallel to W19c safety ground rules); the test pins the negative
assertion — no 'should step up', no 'must step up', no
'recommend step up'.
Adds the /api/medications/[id]/side-effects collection + per-row
endpoints (GET / POST + DELETE), the SideEffectsSection picker UI,
and i18n across the six locales. Mounted on the medication detail
page between the drug-level chart and the intake history; only the
GLP-1 treatmentClass renders the section.
The picker is category → entry chip → 5-Likert severity with an
optional 280-char note. The server derives the category from the
entry server-side — a client-supplied mismatch returns 422, so a
NAUSEA row tagged as INJECTION_SITE never lands in the DB.
POST is rate-limited 30/min/user (matching the inventory route);
DELETE is allowed at any time because the rows are user-owned and
non-clinical, with the audit trail preserving entry + severity +
occurredAt so a forensic reconstruction stays possible.
DE + EN copy hand-curated; FR / ES / IT / PL drafted from EN with
EMA-EPAR-§4.8 terminology. 39 new tests cover the route contract
(auth, ownership, validation, category mismatch, rate-limit) and
the section's static-render shape (empty state, category badge,
severity ladder, entry label, locale switch).
Adds the Research Mode card to Settings → Advanced alongside the
existing danger zone. Toggle ON opens the acknowledgment dialog (the
dialog owns the POST so the user reads the disclaimer first); OFF
fires DELETE directly. An amber re-prompt banner appears above the
toggle whenever the server's currentDisclaimerVersion drifts ahead of
the acknowledgedVersion, surfacing a Re-acknowledge CTA that re-opens
the dialog without flipping the toggle. The Advanced section
description is updated across all six locales to reflect that the
section now hosts both controls.
Recharts AreaChart over a 21-day window, anchored at now. Wires the
W19c-Backend pure PK module: drug id resolved from the medication
brand via the catalog, doses derived from non-skipped intake events
matched against the dose-change history. Y-axis is unit-less per
research §2.3 — no tick labels, the 'Estimated level (relative)'
caption sits above the frame. The chart gates on enabled +
acknowledgedVersion === currentDisclaimerVersion (defence-in-depth
alongside the server 400) and renders distinct placeholders for
each failure mode. Mounted on /medications/[id]/history when
treatmentClass === GLP1.
Opt-in modal that gates the GLP-1 drug-level chart behind an MDR-aware
disclaimer. Reads the live disclaimer version from the server and posts
it back on acknowledge so a server-side bump forces re-acknowledgment.
Sections cover what the chart is, what it isn't, why population PK is
an estimate, the EU 2017/745 + MDCG 2021-24 boundary, and EMA + ASCPT
psp4.13099 citations. Stale-version 400 surfaces a localised error
toast and keeps the dialog open.
New keys (under the existing `onboarding` namespace):
shell.{back,skip,next,step1of4..step4of4,welcomeTitle,welcomeBody}
goals.{title,body,placeholder}
source.{title,body,placeholder}
baseline.{title,body,placeholder}
done.{title,body,returnCta}
EN is the production reference; FR / ES / IT / PL mirror the EN
intent. DE strings are neutral hand-off placeholders pending Marc's
hand-curated copy in a later pass — every value is real text so the
no-empty-values and no-TODO locale-integrity guards keep passing.
Adds a small text-only PR pill that consumers can compose next to
a metric label. The badge pulls /api/personal-records?metricType=
and renders only when the most recent record landed in the last
30 days — older PRs are still visible on the dedicated /insights
PR list but the dashboard surface keeps a fresh-only filter so the
pill is a noticeable signal rather than a permanent fixture.
i18n keys insights.personalRecord.badge and insights.personalRecord.tooltip
ship in all six locales. The badge component is locale-aware
through the standard useTranslations / useFormatters hooks and
exposes a withTooltip prop so standalone uses (doctor report,
correlation cards) can skip the Tooltip provider.
Adds the PERSONAL_RECORD event type to the dispatcher vocabulary
and flips its default policy to opt-in. Without an explicit
preference row the dispatcher now suppresses PR pushes — so a
multi-year Apple Health backfill writes the records but does not
saturate the lock-screen on first sync. Other events keep the
opt-out default they always had.
The /notifications matrix renders the new event automatically
because it iterates EVENT_TYPES; the per-event default helper
keeps the toggle in the right initial state across all six
locales (EN, DE, FR, ES, IT, PL).
Updates the banner notice across all six locales to be explicit that
the AI-initial locales (FR / ES / IT / PL) are AI-drafted including
the Coach's safety-critical instructions, and that automated
validation guards them in CI. The CTA reframes from "help out" to
"report on GitHub" so users with a regression know the
contribution path is bug reporting, not editorial rewrites.
Pins the new wording in the banner test so a future copy regression
cannot quietly soften the disclosure. Banner gating (renders only on
non-maintained locales, SSR-safe) and the GitHub-issue CTA target
URL remain unchanged from W9e.
Add medications.glp1.drug.* namespace with localized INN + drug-class
labels for tirzepatide, semaglutide, liraglutide, dulaglutide, and
exenatide, plus medications.glp1.source.{ema,psp4} citation strings.
Each locale uses the regional spelling convention (DE Tirzepatid,
ES Tirzepatida, PL Tyrzepatyd, …) — scientific names are written
naturally for each market rather than transliterated. Drug-class
labels follow the local pharmacology vocabulary.
All 6 locale files validate; i18n-locale-integrity green.
W8d shipped the opt-in VO2 max trend tile on the dashboard; this is
the deeper Insights surface. Renders one HealthChart for the trend
plus a compact stat strip (latest / min / max / 30-day average with
the comparison delta vs. the prior period) on /insights/puls because
VO2 max sits in Apple Health's Heart category — cardio fitness, not
its own root concept.
Implementation notes:
- vo2Max joins CHART_OVERLAY_KEYS so the per-chart cog persists its
overlay-prefs independently from the pulse chart sitting directly
above it on the same page.
- The card stays mounted at zero samples so a brand-new account sees
the "no data yet" hint rather than a missing surface.
- Compare-delta caption only renders when the user has a comparison
baseline active AND the prior-period bucket has data; otherwise the
slot is suppressed.
- HealthChart is dynamic-imported with the same skeleton placeholder
the trends row uses so Recharts stays out of the Insights bundle.
Translations land in all six locales; FR / ES / IT / PL carry English
placeholders matching the v1.4.25 convention. RTL test exercises the
stat-strip slots, the empty-state branch, the compare-delta gates, and
the German label rendering.
- GLP-1 medication card streak flame: `text-orange-400` →
`text-dracula-orange`. The hardcoded Tailwind shade was skewing
warm against the rest of the card.
- /insights/medikamente compliance counters: `text-green-500` /
`text-orange-500` / `text-red-500` → `text-dracula-green` /
`-orange` / `-red`. Same drift in three sibling spans on one
surface; replace as a set.
- Side-effect affordance: `Smile` icon read as a positive
affordance for a *negative* logging surface. Switch to
`Stethoscope` so the icon matches the clinical context.
- Health Score componentBp: FR `BP` → `TA`, IT `BP` → `PA`,
ES `BP` → `PA`, PL `BP` → `RR` — the native compact forms
used in clinical practice. DE "Blutdruck" + EN "BP" unchanged.
- Therapy-timeline `<li>` key: array index → composite
(`kind-date-medicationName`). Prevents DOM-reuse bugs when a
dose change is logged mid-window and reorders the list.
- Therapy-timeline inline icons: `aria-hidden="true"` on the
`<Pill>` injection glyph and the dose-arrow `<ArrowUp>` /
`<ArrowDown>` so screen readers stop announcing "image"
mid-sentence.
- Medication-form Dialog tap targets: the schedule-row dropdown
trigger (`h-7 w-7`) and the New-schedule button + the four
schedule inputs (`h-8`) promoted to `h-11` so the dense
schedule grid honours the 44px floor. The labels stay compact;
the controls grow.
Extends the W8.3 44px touch-target rule and the W8e accordion ARIA
contract to surfaces that shipped without them:
- Insights tab-strip pills: pills are primary navigation; promote to
`min-h-11` so a thumb tap on "Stimmung" cannot bleed into the
neighbour two pixels below.
- Injection-site picker: raise the invisible hit-target radius from
11 to 12 SVG units so the rendered diameter (\~25.6 CSS px)
clears the WCAG 2.5.8 Level AA 24×24 floor. The abdomen left/right
pair at \Δx=24 caps the upper bound — larger circles would
collide.
- Source-priority reorder arrows (per-metric + device-type axes):
promote four `h-7 w-7` icon buttons to `h-11 w-11`.
- Sub-page heading: replace the bare `focus-visible:outline-none`
with the same ring vocabulary used by insights pills + the Coach
drawer so sighted-keyboard users get a visible indicator on
programmatic focus.
- Source-priority per-metric + device-type accordions: pair
`aria-expanded` with `aria-controls` + matching `id` on the
panel, mirroring the W8e Provenance contract.
- Maintainership banner dismiss button: 22×22 hit zone replaced by a
44×44 inline-flex button that keeps the same icon size.
- Injection-site picker group: `aria-label` was leaking the
cadence-preset string; introduce
`medications.injectionSitePickerAriaLabel` and translate it
across all six locales.
- Body-outline SVG: hardcoded English "Body outline" replaced by
`medications.injectionSiteBodyOutlineAriaLabel` with native
translations in DE/FR/IT/ES/PL.
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()`.
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.
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).
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.
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.
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.
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.
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.
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*`.
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.
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.
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.
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.
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.
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.
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.
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)
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.
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.
Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
in phase 3)
v1.4.25 W7 phase 5 — proposal §5 admin half.
GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
- empty string clears the column (resolver falls back)
- non-empty must pass isValidTimezone(); otherwise 422
- successful writes invalidate the server-default resolver cache
so the registration handler sees the new value on the next
signup without waiting 60s for the TTL
The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'
Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
v1.4.25 W7 phase 4 — proposal §5.
Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.
Form save uses Promise.all over two PUTs:
- /api/auth/profile (email, height, dob, gender)
- /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.
The new route PUT /api/auth/me/timezone:
- requireAuth gates the cookie + Bearer paths
- 422 on any string the runtime's Intl.DateTimeFormat rejects
- 200 on success, returns the persisted value
- audits 'user.timezone.update' with previous + next
- invalidates the resolver cache so the next read sees the new
value within one tick instead of waiting 60s for the TTL
i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'
Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
Marc directive 2026-05-14: the page-level regenerate affordance has to
stay reachable while the user is reading the per-metric cards. Up to
v1.4.24 it lived inside the hero band's action row, which scrolled
away as soon as the user moved past the briefing.
What changed:
- New `src/components/insights/insights-tab-strip.tsx` carries the
six metric pills + the regenerate icon button. The strip is
`sticky top-0` with `bg-background/95 backdrop-blur` so it lives
above every section as the user scrolls.
- Right slot: a 44×44 icon-only `<RefreshCw>` button with
`aria-label={t("insights.regenerateAnalysis")}` and a sonner
success toast keyed to the falling edge of `regenerating`
(rising-edge guard via a ref so the toast only fires when an
in-flight regen finishes, not on every render).
- The hero strip's `onRegenerate` / `regenerating` props are gone;
its action row is now Weekly-report + Ask-the-Coach only. The
JSDoc comment block points to the new tab strip for the
regenerate affordance.
- `src/app/insights/page.tsx` mounts `<InsightsTabStrip
onRegenerate={advisor.regenerate} regenerating={...} />` and
drops the inline `InsightsSectionNav` function (its logic moved
into the new component verbatim).
- Two new translation keys added under the `insights.*` namespace:
`regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis")
drives the aria-label + title; `regenerateSuccess` ("Analyse
wurde neu erstellt" / "Analysis refreshed") drives the toast.
The existing hero-strip test that pinned the regenerate slot was
flipped — the slot must NOT render under any prop combination. The
insights-polish test that asserted `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the strict insight schema, Coach snapshot pipeline, and
provenance envelope with the seven Apple Health categories that landed
in Wave 2 (HRV, sleep, resting HR, steps, active energy, flights,
distance, VO2 max, body temperature). All fields are additive and
optional — web-only accounts pay zero extra SQL and the prompt's new
GROUND RULE 12 instructs the model to stay silent when the snapshot
doesn't carry the HealthKit blocks.
Ratchets PROMPT_VERSION 4.22.0 → 4.23.0 and refreshes the
sourceMetric / trendAnnotations enums in both EN + DE. Two new test
files pin the prompt body and the snapshot fan-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The measurements list now paints `APPLE_HEALTH` rows with a Dracula-
pink chip whose accent matches the iOS app's brand. The localised
label ships in both English and German via the new
`measurements.sourceAppleHealth` key; the helper falls back to the
SCREAMING_SNAKE value for unknown sources so a missing key would
surface immediately instead of silently rendering blank.
Admin backup restore guards (`MEASUREMENT_TYPES`,
`MEASUREMENT_SOURCES`) gain the seven new HealthKit-shaped types and
the new source value so a restore from a backup that contains Apple
Health rows doesn't get rejected as malformed.
A new badge regression test mirrors the existing BP-badge guard:
asserts the localised label paints, the Dracula-pink utility classes
land, and the bilingual locale still renders the same brand spelling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.
The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.
Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>