diff --git a/.planning/STATE.md b/.planning/STATE.md
index 304bb12a6..55da2e290 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,7 +1,7 @@
# v1.4.22 marathon — state log
-Status: Wave 3 — Coach polish complete
-Last update: 2026-05-10T21:00+02:00
+Status: Wave 4 — backlog cleanup + bug closure complete
+Last update: 2026-05-10T21:45+02:00
> Previous patch: v1.4.21 live (image digest
> `sha256:4e818d44702c…`, `/api/version=1.4.21`).
@@ -119,41 +119,51 @@ rewrite needs research first.
### C1 — Zielwerte page upgrade
-- [ ] Visual + UX brought to the v1.4.20 polish bar
+- [x] 30-day sparkline + Δ-vs-last-month caption per card (Direction
+ A from W1a §4). Reuses shared HealthChart styling via a
+ dependency-free inline SVG; API returns `points30d` +
+ `deltaVsLastMonth`. BMI derives both from the weight series.
### C2 — Admin / api-tokens scrollbar 5th attempt
-- [ ] Live Playwright probe: which element actually overflows
-- [ ] Patch the right layer this time
+- [x] Live Playwright probe confirmed `whitespace-nowrap` on the date
+ `
`s was the residual culprit (W1a §2). Dropped the two
+ classes; date+time wraps to two lines on narrow viewports.
### C3 — Coolify image-digest auto-deploy
-- [ ] Wire watchtower-via-Coolify path OR enable Coolify
- image-digest checkbox
-- [ ] Validate end-to-end: tag push → fresh image deployed without
- host-side retag
+- [x] Workflow appends `?force=true` to the deploy webhook so doc-only
+ pushes still trigger a registry-digest check. Maintainer-side
+ toggle ("Watch image registry for new digests") documented in
+ `.planning/coolify-auto-deploy-howto.md` — one UI flip.
### C4 — AuthShell post-hydration redirect flicker
-- [ ] Move null-`onboardingCompletedAt` redirect to `proxy.ts`
- server-side, OR gate dashboard render on the field
+- [x] Redirect lives in `proxy.ts`. Auth routes mirror
+ `onboardingCompletedAt` into a non-httpOnly `hl_onboarding`
+ cookie so the proxy can short-circuit before hydration. The
+ e2e onboarding-flicker spec dropped its workaround.
### C5 — node-26-alpine bump
-- [ ] Investigate the dependabot PR that failed; either patch the
- Dockerfile to support node 26 or close the PR with reasoning
+- [x] Dependabot PR #162 closed with reasoning. node:26-alpine ships
+ without corepack pre-installed; Next.js 16 also still pins
+ node 22 LTS as the supported minimum. Reopen when the framework
+ bumps its node-version requirement.
### D — `.planning/v1421-backlog.md` cleanup wave
Apply or confirm-defer every item:
-- [ ] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe)
-- [ ] F6 docs site audit (9 MED + 5 LOW)
-- [ ] F5 best-practice (5 LOW)
-- [ ] FX carry-overs:
- - [ ] 191 `Marc` references in `src/` source comments
- - [ ] DE+EN bilingual CHANGELOG entries normalised to EN
- - [ ] `CLAUDE.md` + `AGENTS.md` filename rename or retire decision
+- [x] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe)
+- [x] FX carry-overs:
+ - [x] 191 `Marc` references swept across `src/` (test fixtures kept
+ as opaque test data per brief)
+ - [x] DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15) reduced
+ to English-only
+ - [x] `CLAUDE.md` renamed to `CONTRIBUTING-AI.md` so the filename
+ is not AI-vendor-specific; `AGENTS.md` stays for multi-agent
+ compatibility. `CONTRIBUTING.md` reference updated.
### E2E fix wave (already on develop)
diff --git a/.planning/phase-W1a-report.md b/.planning/phase-W1a-report.md
new file mode 100644
index 000000000..229d5530a
--- /dev/null
+++ b/.planning/phase-W1a-report.md
@@ -0,0 +1,508 @@
+# Wave 1a — PROD probe + token-leak hunt + backlog inventory (v1.4.22)
+
+Started: 2026-05-10T20:20+02:00
+Branch: develop (post-v1.4.21)
+Production probed: `https://healthlog.bombeck.io` (image digest
+`sha256:4e818d44702c…`, v1.4.21)
+Probe scripts (read-only, no commits):
+- `.planning/v1422-w1a-tokens-probe.mjs` (admin/api-tokens overflow walk)
+- `.planning/v1422-w1a-insights-probe.mjs` (Insights `Metrik` / token leak hunt)
+- `.planning/v1422-w1a-targets-probe.mjs` (Zielwerte page screenshot)
+
+## 1. BD-Zielbereich tile
+
+### What I see today
+
+`src/components/charts/trend-card.tsx` paints the BD tile in the
+shared `` chrome:
+
+- **Headline (3xl bold):** `latest = data.bpInTargetPct` — routed through
+ `windows.allTime?.pct` since v1.4.19 A1. For the live tenant this is
+ ~10.8 % rounded to 11 %.
+- **Sub-row (`mt-auto` muted text-xs):** `7d: · 30d: ` —
+ `data.bpInTargetPct7d / pct30d`. For the live tenant, both 50 %.
+- No window label on the headline. The tile reads **"11 % … 7d 50 30d 50"**,
+ a juxtaposition that punishes the recent improvement: the headline
+ is the story (large + bold) but represents the slowest-moving
+ aggregate possible (every paired BP reading since 2022).
+
+The cognitive flow is "your number is 11 %" → "wait, the recent
+windows say 50 %" → "which one is real?". Even after grok-ing that the
+headline is all-time, the reader is left with a number that is
+technically correct but emotionally wrong: a user who put in real
+recent work to drag the rate up sees an unmoving aggregate that
+takes years of continued discipline to budge.
+
+### Three proposals (recommend one)
+
+#### A) Re-anchor headline to last-30d, surface all-time as a sub-value
+
+Pro:
+- The dashboard tile becomes a "what's happening now" widget, matching
+ every other tile on the page (weight, pulse, mood — all show recent
+ reading + 7d/30d). The BD tile is currently the only one whose
+ headline is an all-time number.
+- The 50 % feels earned; users see "improving" as movement and the
+ sub-line gives historical context without dominating.
+- One-line API change: `bpInTargetPct = windows.last30Days?.pct` plus
+ a new field `bpInTargetPctAllTime` for the sub-row.
+
+Con:
+- Inverts the v1.4.19 A1 fix story. Need a backlog entry to clarify
+ that the v1.4.19 fix was about *correctness* (the headline was
+ silently equal to the 30-day) and v1.4.22 is about *framing* (the
+ all-time aggregate belongs in the small text).
+- Cache invalidation on the `analytics` query (existing TanStack
+ pattern handles this).
+
+#### B) Explicit window label "Allzeit / All time" on the headline
+
+Pro:
+- Smallest diff. Add a `{t(
+ "dashboard.bpInTargetAllTimeLabel")}` next to the 11 %. No
+ data-shape change, no API change.
+- Honours the v1.4.19 A1 contract verbatim — the headline still
+ surfaces the slowest-moving number.
+
+Con:
+- The cognitive dissonance remains: the user still reads the big
+ number first and the label second. A 50 % → 50 % → 11 % "punishment
+ reading" persists; the label only confirms why.
+- No other tile on the page carries a window label on its headline,
+ so this is a one-off pattern.
+
+#### C) Three-window chip row (no headline) — replace big-number layout
+
+Pro:
+- Renders the tile as three equal-weight chips: `7d 50 % · 30d 50 % ·
+ Allzeit 11 %`. No single number dominates; the user picks the
+ window that matters to them.
+- Visually distinct enough to break the "every TrendCard is identical"
+ uniformity, which signals to the user that BP-in-target is a
+ conceptually different metric than weight / pulse.
+
+Con:
+- Largest design lift. Diverges from the shared `` chrome
+ used everywhere else on the dashboard, so either (a) BD tile gets
+ a bespoke `` component, or (b) the shared TrendCard
+ picks up an optional "windowed mode". Either is a Wave-2/3 redesign
+ item, not a Wave-4 polish patch.
+- Mobile tile is already dense at 50 %/50 % — fitting three chips
+ is a viewport challenge.
+
+### Recommendation: **A** (re-anchor to last-30d, all-time → sub-row)
+
+Smallest UX win for the smallest engineering lift, and aligns the BD
+tile with every other tile on the dashboard. The all-time number
+stays visible as a sub-value alongside `7d` and `30d`, so power users
+can still reference it. The v1.4.19 A1 contract is preserved (the
+all-time aggregate is computed and surfaced) — only the visual
+priority changes. The fix is roughly:
+
+- `src/app/api/analytics/route.ts`: split `bpInTargetPct` (allTime)
+ → keep as `bpInTargetPctAllTime`; promote `windows.last30Days.pct`
+ to the new `bpInTargetPct` headline.
+- `src/components/charts/trend-card.tsx` — extend `` with
+ an optional `tertiaryAvg` slot OR (cleaner) accept a third
+ sub-value pair (`avgAllTime`, `avgAllTimeLabel`) and render it as
+ `· Allzeit: %` next to `7T:` and `30T:`. Keep the change
+ optional + behind feature-detection so other tiles don't pick up
+ the third row.
+- Tests: `bp-in-target.test.ts` already pins the three-window
+ contract; add a `` test that asserts the headline
+ matches the 30-day pct when both are passed.
+
+## 2. Admin / api-tokens scrollbar
+
+### Per-viewport probe results
+
+| Viewport | viewport×inner | doc overflow | body overflow | Real culprit |
+| ------------------- | -------------- | ------------ | ------------- | ------------------------------------------ |
+| iphone-se 375×667 | 375 / 375 | NO | NO | (none — only the intentional `nav.no-scrollbar` mobile section strip) |
+| pixel5 393×851 | 393 / 393 | NO | NO | (none — same intentional nav) |
+| ipad-mini 768×1024 | 768 / 768 | NO | NO | `
` (intentional `auto`, BUT triggers because the inner table overflows) — table sw=212, cw=122, delta=90 |
+| desktop 1280×800 | 1280 / 1280 | NO | NO | Same wrapper. Table sw=663, cw=634, delta=29 |
+
+### Real culprit
+
+**The desktop table overflows its `overflow-x-auto` wrapper at md+ even
+on a 1280-wide desktop viewport.**
+
+Selector: `div.hidden.overflow-x-auto.md:block` →
+`table.w-full.table-fixed.text-sm` (file:
+`src/components/admin/api-token-overview-section.tsx:145-238`).
+
+Why the table overflows even with `table-fixed` + percentage colgroup:
+
+1. The "Last used" column (`
`) carries
+ `whitespace-nowrap` (line 225) and renders strings like
+ `"05.05.2026, 21:46"` (~14 chars after locale) → ~110 px at the
+ table's font size.
+2. The "Created" column (`
`) also carries
+ `whitespace-nowrap` (line 230) and renders the same locale-aware
+ `"05.05.2026"` ~80 px wide.
+3. At 1280 px viewport with sidebar 220 px + gap 24 px + page padding
+ ~64 px, the card's content area ≈ 700 px. 12 % of 700 = 84 px,
+ 8 % = 56 px. The `whitespace-nowrap` strings (~110 + ~80 px)
+ overflow their colgroup-allotted percentages.
+4. `whitespace-nowrap` wins over `table-fixed`'s width contract, so
+ the table's intrinsic width exceeds 100 %. The wrapper's
+ `overflow-x-auto` then paints a horizontal scrollbar to let the
+ user see the clipped columns — **that is the scrollbar Marc keeps
+ reporting**.
+
+Visual confirmation: `/tmp/v1422-w1a/desktop-fullpage.png` shows the
+"Last used" and "Create" headers truncated mid-word and the rightmost
+date strings clipped (e.g. "05/05/" with the year cut off).
+
+### Concrete fix path
+
+Three options, in order of preference:
+
+1. **Drop `whitespace-nowrap` on the date cells** (lines 225, 230).
+ `formatDateTime("05.05.2026, 21:46")` is two semantic chunks; let
+ it wrap to two lines on narrow viewports. The card grows by one
+ row but no scrollbar — and the desktop case at >1024 px already
+ fits the unwrapped string in 12 %.
+
+2. **Compact date format on the "Last used" column.** Use
+ `formatDateShort()` (already in `src/lib/format.ts:51`) which
+ drops the year. "05.05" + time fits in 70 px; the trailing
+ tooltip already exposes the full timestamp on hover.
+
+3. **Hide the "Created" column on md (768-1023 px).** Add a
+ `hidden lg:table-cell` to the 6th `
` + `
` + `
`. The
+ tablet user trades one column for a clean layout; desktop keeps
+ everything.
+
+Option 1 is smallest diff (two-line change, no i18n surface impact).
+Option 2 is the cleanest UX. Option 3 is what the "User"/"Permissions"
+columns already do via the mobile card-list — same pattern, one
+breakpoint up.
+
+## 3. Raw `metric:` token leaks
+
+### Where they emit
+
+The Playwright probe captured the literal substrings rendered in the
+production /insights page (German locale, live-tenant data):
+
+| Match | Context (rendered prose, last 60 + 60 chars) |
+| --------------------------- | --------------------------------------------------------------------------------------------- |
+| `metric:BLOOD_PRESSURE_SYS` | "…strukturiertes Gegensteuern sinnvoll ist. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
+| `metric:BLOOD_PRESSURE_SYS` | "…Medikation mit niedrigeren systolischen Werten einhergeht. metric:BLOOD_PRESSURE_SYS\n\n…" |
+| `metric:WEIGHT` | "…damit ich die BMI-Klassifikation präzise berechnen kann. metric:WEIGHT\n\nZuletzt aktualisiert" |
+| `metric:PULSE` | "…und aktuell, daher ist die Aussage belastbar. metric:PULSE\n\nZuletzt aktualisiert" |
+| `metric:MOOD` | "…nur 4 % Zielbereichstreffern in den letzten 30 Tagespunkten. metric:MOOD\n\nZuletzt aktualisiert" |
+| `metric:BLOOD_PRESSURE_SYS` | "…regelmäßig nur eine von zwei geplanten Tagesdosen. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
+
+The `Zuletzt aktualisiert: …` timestamp tail is the per-section
+advisor card footer — so the leak is appearing at the END of an
+**advisor card recommendation text** (not in the `summary` or
+`primaryRecommendation`, both of which are stripped).
+
+### Where the strip is missing
+
+`src/components/insights/recommendation-card.tsx:336`:
+
+```tsx
+
{norm.text}
+```
+
+`norm.text` (= `rec.text`) is rendered **directly without
+`stripChartTokens()`**. Every other AI-prose surface on the page —
+`insight.summary` (line 594), `insight.primaryRecommendation` (line
+573), `finding.label` (line 622), `finding.guideline` (line 631) —
+runs through `stripChartTokens()`. The recommendation text is the one
+field the v1.4.19 A3 widening missed.
+
+The model is following its own prompt (it embeds `metric:WEIGHT` etc.
+in the rec text expecting an inline mini-chart), but the rec-card's
+chart slot lives in the EXPANDED rationale block (line 226-234, only
+visible after the user clicks the chevron), so the token has nowhere
+to be consumed in the always-visible rec text — it just leaks.
+
+### Concrete fix
+
+Two-line diff:
+
+```diff
+-
{norm.text}
++
{stripChartTokens(norm.text)}
+```
+
+plus the `import { stripChartTokens } from "@/lib/insights/chart-tokens";`
+at the top of `recommendation-card.tsx`.
+
+Backstop test in the existing `insights-polish.test.ts` describe-block
+(`v1.4.19 A3 — chart-token leak hardening`) — add an
+`it("RecommendationCard strips metric:* tokens from rec.text")` case
+that mounts a rec with `text: "increase fluids. metric:WEIGHT"` and
+asserts the rendered DOM does NOT contain `metric:WEIGHT`.
+
+### Bonus translation defect uncovered en route
+
+Marc reported "Metrik Mut" — the actual rendering on prod is the
+**Health-Score sub-bar label**, which surfaces "Mood" verbatim in
+German because `messages/de.json:778` says:
+
+```json
+"componentMood": "Mood",
+```
+
+instead of `"Stimmung"`. The label is rendered by
+`src/components/insights/health-score-card.tsx:65` via
+`COMPONENT_LABEL_KEY.mood = "insights.healthScore.componentMood"`.
+
+One-character fix. Worth bundling into the same Wave-4 patch so
+"the BP/Weight/Mood/Meds row" reads natively German.
+
+## 4. Targets / Zielwerte page
+
+### Current state (probe screenshots)
+
+`/tmp/v1422-w1a-targets/desktop.png` (1280×900) and
+`/tmp/v1422-w1a-targets/pixel5.png` (393×851).
+
+The page is a 2-col (desktop) / 1-col (mobile) grid of identical
+`` instances. Each card has:
+
+- Card header: icon + label + small trend arrow (TrendingUp/Down/Minus)
+- Big-number current value (with unit and 30-day mean line)
+- Horizontal three-zone range bar (red / yellow / green) with a dot
+ marker for the current value
+- Status pill (e.g. "Optimal", "Im Zielbereich", "Moderat")
+- External-link to the source guideline (WHO BMI / ESH 2023 /
+ CDC/NCHS …)
+
+Compared to `/dashboard` and `/insights` the page is visually
+**static and reference-y**:
+
+- No 7-day delta. Only the 30-day mean.
+- No sparkline / mini-chart in the card. The user sees the *current*
+ value but no story of how they got there.
+- No "Δ vs last month" callout (the comparison toggle pattern shipped
+ in v1.4.16 phase B8 lives only on the dashboard).
+- No grouping. BD-Zielbereich (50 % in target) sits next to BMI
+ (Normal) sits next to Einnahmetreue (64 %) — three completely
+ different metrics styled identically.
+- Status pills are passive — they label the current bucket but don't
+ invite action ("So what do I do about this?").
+
+The "ungelebt" feeling is real: every card is a flat reference card
+with no momentum, no comparison, no "your trajectory" angle. It looks
+like a guideline cheat-sheet, not a living health log.
+
+### Three upgrade directions (pick one for Wave 4)
+
+#### A) Sparkline-per-card + "Δ vs last month" callout
+
+Each `` grows a 30-day mini-sparkline beneath the range
+bar (same `` already used in the InlineCharts of
+`/insights`). A second-line "Δ −2.3 kg vs last month" caption mirrors
+the dashboard's comparison toggle so the user sees movement at a
+glance. Status pill stays.
+
+Pro: reuses existing components (HealthChart mini, comparison delta,
+TrendCard's metric-sentiment colour rules). Each card becomes a
+"current state + recent journey" block.
+
+Con: doubles card height. 12 cards × +120 px is +1440 px scroll on
+mobile. Need a "compact mode" toggle.
+
+#### B) Status-row + drill-down — collapse cards into a scannable list
+
+Replace the grid with a vertical list. Each row: icon + label +
+current value + status pill + small trend arrow + chevron to expand.
+Expansion reveals the range bar + 30-day chart + source link.
+
+Pro: 12 metrics fit on one screen. Glance answer first, detail on
+demand. Mirrors the iOS Health pattern users know.
+
+Con: Bigger redesign — at-a-glance loses the range-bar visualisation
+that gives the page its "where am I in the band?" answer. Might be
+too clinical.
+
+#### C) Storyline framing — a short headline + 3-target hero, then everything else
+
+The page opens with a one-sentence summary "Du bist in 8 von 12
+Zielbereichen.", then surfaces the 3 most-out-of-target metrics as
+hero cards with bigger range bars + a "What changed?" callout, with
+the remaining 9 collapsed below as a compact summary strip.
+
+Pro: solves the "ungelebt" problem with a narrative. Targets become a
+state-of-your-health page, not a reference book. The hero strip ties
+back to the `` pattern shipped on `/insights` for visual
+consistency.
+
+Con: requires server-side ranking ("which targets are most out of
+band?"), so this is a Phase-2 redesign. Out of scope for a polishing
+marathon. **Defer to v1.5.**
+
+### Recommendation: **A** (sparkline + Δ-vs-last-month)
+
+Lowest effort, highest "the page now feels alive" payoff, reuses
+existing components. The mobile-height cost is mitigated by collapsing
+the range bar's surrounding meta into a single row. Direction B is a
+v1.5-scope rethink; direction C is a v1.6 product call.
+
+For Wave 4 the lift is roughly:
+
+- Add `30dPoints?: number[]` to the `TargetData` API shape (server
+ already has the data — `/api/insights/targets` reads from the same
+ Prisma rows the dashboard does).
+- Mount `` inside
+ `` between the range bar and the classification row.
+- Pipe the comparison delta from the existing `compare` server route
+ (used by the dashboard) into the page query, render it as a
+ `` row above the range bar.
+
+## 5. v1.4.21 backlog inventory
+
+Source: `.planning/v1421-backlog.md` (301 lines). Items grouped by
+complexity bucket below.
+
+### Quick wins (apply en masse in Wave 4 D — ≤5 lines diff each)
+
+- **Sec-L-1** — `coachChatRequestSchema.conversationId` regex tightening to
+ `^c[a-z0-9]{24}$` or `[A-Za-z0-9_-]{8,64}`. One-line schema change.
+- **Sr-LOW-1** — drop redundant `@@index` on `coach_usage`; `@@unique`
+ already builds it. One-line schema removal.
+- **Code-MED-04** — reuse `buildWeightRangeFromHeight()` for
+ `weightTrendAlignment`'s ±2 kg target band. Single import + call swap.
+- **Code-MED-07** — drop the dead `confidence` prop from ``
+ call site. One-line removal.
+- **Design-L1** — drop the generic "personal-baseline" hero copy line.
+ One-line removal.
+- **Design-L5** — append `…` suffix when storyboard 24-char truncation
+ fires. Three-line addition in
+ `src/components/insights/storyboard-row.tsx`.
+- **Design-L6** — replace German `Umschalt+Enter` with literal
+ `Shift+Enter` to match the rest of the app. One i18n key change.
+- **Code-LOW-01** — wrap `` greeting + relativetime in
+ `useMemo`. Two-line change.
+- **Code-MED-09** — wrap storyboard payload through
+ `storyboardAnnotationsSchema.safeParse`. Pattern-copy from
+ `dailyBriefing` lift; ~5 lines.
+- **Sr-MED-1** — add 3 cascade-delete test rows for
+ `coachConversation` / `coachMessage` / `coachUsage`. 5 lines.
+- **Sr-LOW-4** — tighten `weeklyReport.weekISO` regex to validate
+ weeks 01-53. One regex change.
+- **S-06** — drop dead `historyRail` / `sourcesRail` / `composer` slot
+ props on ``. ~10 lines deletion.
+- **S-07** — drop the never-overridden `inputId` prop on
+ ``. One-line removal.
+
+**Wave-4 quick-wins count: 13 items**, total estimated diff ≤
+~80 lines across 11 files. **Recommend folding all 13 into Wave-4 D.**
+
+### Medium (one new helper / one component refactor)
+
+- **Code-MED-02** — `bandFromInterval` "high" label on near-zero r —
+ rename chip to "tight CI" / "wide CI" or demote when CI straddles
+ zero. Touches the helper + every chip caller.
+- **Code-MED-03** — Pearson p-value normal-approx error at low df.
+ Either pull in incomplete-beta (~30 LOC) or raise df gate to 20.
+ Decision call.
+- **Code-MED-05** — `bpInTargetPct` "vs last week" delta is held
+ constant. Recompute prior-snapshot rate or relabel the line.
+- **Code-MED-06** — `WeeklyReportView` print spinner timer reset
+ bug. Pin via ref.
+- **Code-MED-08** — `provenanceFromJson` cast — validate against
+ `WINDOW_KEYS` allow-list.
+- **Code-LOW-03** — Promote nested `` portals on mobile rail
+ trays to siblings.
+- **Code-LOW-05** — add `bpInTargetRate` constant-test gap test.
+- **Code-LOW-06** — `streamRefusal` budget meter accounting.
+- **Sec-M-2** — call `checkRateLimit()` before refusal scanning OR
+ bump `messageCount` inside `streamRefusal()`.
+- **Sec-M-3** — extend `INJECTION_PATTERNS` with synonyms +
+ zero-width strip pass. Maintenance pattern, ~30 LOC.
+- **Sec-M-4** — wrap `appendMessage(assistant)` + `recordSpend()` in
+ Prisma `$transaction`.
+- **Sec-L-3** — skip `updatedAt` bump for refusal appends OR hide
+ refusal-only conversations.
+- **Sec-U-1** — Vitest case smoke-testing `request.clone()` against
+ Next.js 16.
+- **Sr-MED-2** — lift `weeklyReport` + `storyboardAnnotations` through
+ `safeParse`.
+- **Sr-MED-3** — wrap `handleChatRequest` in `enforceRateLimit()`.
+- **Sr-MED-6** — offer `withIdempotency(handler, { skipWhen })` for
+ conditional opt-out.
+- **Sr-LOW-2** — migrate `metricSourceJson` from `String` to `Jsonb`.
+- **Sr-LOW-3** — coach off-topic detector locale-mixed tightening.
+- **S-08** — re-read `cloneForCheck` dead variable.
+- **S-09** — compress health-score-card delta-arrow `&&` blocks into
+ a `deltaIcon` lookup. Borderline.
+- **Design-M1** — Hero strip greeting clock → Berlin via
+ `Intl.DateTimeFormat`.
+- **Design-M3** — Health Score panel mobile order inversion.
+- **Design-M5** — Coach drawer focus trap — reparent rail-tray
+ Portal container to SheetContent.
+- **Design-M6** — Tone-bar visual clip at rounded corner.
+- **Design-M8** — "n=N" reads as engineering output — use `{count}
+ samples` / `{count} Werte`.
+- **Design-L2** — Health Score sub-bars per-component band.
+
+**Wave-4 medium count: 26 items.** Recommend Wave-4 picks the BP-tile
+fix (Code-MED-05), the Sec-M-2/3/4 cluster (small but security-adjacent),
+the `safeParse` lifts (Sr-MED-2 + Code-MED-08 + Code-MED-09), and the
+2-3 design polishes that ride along (Design-M1 / M6 / M8). **Defer
+the rest to v1.4.23.**
+
+### Heavy (cross-cutting, schema, or larger refactor)
+
+- **Sr-HIGH-2** — Consolidate Pearson + linear regression duplicated
+ maths layer into `correlation.ts` + `regression.ts`. **v1.4.23**.
+- **Sr-HIGH-4** — `` weaponising React keys
+ for state reset. Make `prefill` fully-controlled. **v1.4.23**.
+- **Sr-MED-4** — Wire provider runner to true streaming with `onToken`
+ callback. Unlocks "stop generating" as a real feature. **v1.5**.
+- **Sr-MED-5** — `medication_schedules.days_of_week` schema drift.
+ Either land the column with backfill or drop the field. **v1.4.23**
+ (schema change with downstream impact).
+- **Code-MED-01** — Streaming auto-scroll deps brittleness fix.
+ Touches MessageThread effect and a new turn-counter hook.
+ **v1.4.23**.
+
+**Wave-4 heavy count: 5 items, all deferred.**
+
+### Already obsolete (resolved en passant or no longer relevant)
+
+- **Sec-U-2** — `enforceBudget` race window. Acceptable in practice;
+ per-day cap. Annotation-only suggestion. Promote to "not a defect"
+ and close.
+- **Sr-MED-6** carry-over — `apiHandler`-vs-`withIdempotency` stacking
+ is "largely moot now that the chat route doesn't wrap in
+ idempotency". The carry-over note documents that — close as obsolete.
+- The Docs-site MED + LOW items (versions stale, model count, schedule
+ timezone, hashing wording) — these are cross-repo to
+ `healthlog-docs`. **Out of scope for v1.4.22 HealthLog work**;
+ belong in the docs repo's own next sweep. Move to a separate file
+ `docs-followup.md` or close as out-of-repo.
+- F5 README badges, FUNDING.yml, `.gitignore` audit, repo-root tidy —
+ cosmetic only. The README badge add is useful but not v1.4.22-blocking.
+ **Push to a hygiene PR**.
+- `CLAUDE.md` / `AGENTS.md` filename rename, source-comment "Marc"
+ sweep, DE+EN bilingual CHANGELOG normalisation — **defer to a
+ hygiene PR series in v1.5**. The filename rename in particular is
+ structural and would affect every IDE / CI tool that special-cases
+ these filenames; a one-line v1.4.22 patch is the wrong vehicle.
+
+**Obsolete count: 7 items.** Mark closed in the v1.4.21 backlog,
+move docs items to `docs-followup.md`, and rebase v1.4.22 backlog
+to start with the 13 quick-wins + 8-10 medium picks above.
+
+---
+
+## Summary numbers
+
+- **Quick-wins to fold into Wave-4 D:** 13 items, ~80 LOC total
+- **Medium picks to fold into Wave-4 (selectively):** 8-10 of 26
+- **Heavy items deferred to v1.4.23 / v1.5:** 5
+- **Obsolete to close:** 7
+
+Probe artifacts:
+- `/tmp/v1422-w1a/findings.json` + 4 viewport screenshots
+- `/tmp/v1422-w1a-insights/findings.json` + 3 locale screenshots
+- `/tmp/v1422-w1a-targets/desktop.png` + `pixel5.png`
diff --git a/.planning/phase-W4-report.md b/.planning/phase-W4-report.md
new file mode 100644
index 000000000..86d76912a
--- /dev/null
+++ b/.planning/phase-W4-report.md
@@ -0,0 +1,156 @@
+# Wave 4 — Other surfaces + backlog cleanup (v1.4.22)
+
+Started: 2026-05-10T21:15+02:00
+Completed: 2026-05-10T21:55+02:00
+Branch: develop (post-Wave-3 Coach polish, all five v1.4.21 e2e fix
+commits preserved)
+
+## Six commits shipped
+
+| # | Subject | Verdict |
+| - | ------------------------------------------------------------------------------------ | --------- |
+| 1 | feat(targets): 30-day sparkline + Δ-vs-last-month caption per card | shipped |
+| 2 | fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt) | shipped |
+| 3 | ops(deploy): force=true on Coolify webhook + node-26-alpine investigation | shipped |
+| 4 | fix(auth): move onboarding redirect from AuthShell useEffect to proxy | shipped |
+| 5 | i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums) | shipped |
+| 6 | chore: voice + filename hygiene (Marc sweep, bilingual CHANGELOG, CLAUDE.md rename) | shipped |
+
+## C1 — Zielwerte sparkline + delta
+
+Direction A from W1a §4. Each `TargetCard` grows a tiny inline SVG
+sparkline beneath the range bar + a localised "Δ −2.3 kg vs. last
+month" caption. The API ships `points30d` + `deltaVsLastMonth` per
+target; both are null when either window has fewer than 3 readings so
+cold-start accounts don't see a misleading flat trace. BMI piggybacks
+on the weight series (divided by height²) so its sparkline shares the
+range bar's y-axis. The new vitest spec
+`src/app/__tests__/targets-sparkline.test.tsx` pins four invariants.
+
+## C2 — api-tokens scrollbar 5th attempt
+
+Picked option 1 of W1a §2's three: drop `whitespace-nowrap` on the
+date `
`s. The v1.4.19 production probe showed the formatted
+date+time string ("05.05.2026, 21:46") exceeded its 12% colgroup
+allotment (~84px on a 700px content area) and `whitespace-nowrap`
+won over `table-fixed`'s width contract, so the table's intrinsic
+width exceeded 100% and the wrapping `overflow-x-auto` painted the
+scrollbar. Dropping the two classes lets the cell wrap to two lines
+on narrow viewports — one extra row of height in exchange for a
+scrollbar-free admin page on every viewport. Regression guarded by a
+new unit test inside `api-token-overview-responsive.test.tsx`.
+
+## C3 — Coolify auto-deploy
+
+Workflow now appends `?force=true` (or `&force=true` when the secret
+URL already has query params) so a doc-only push still triggers a
+registry-digest check on Coolify's side. The matching maintainer-side
+step ("Watch image registry for new digests" checkbox in the Coolify
+application config) is documented in
+`.planning/coolify-auto-deploy-howto.md` — one UI click, no code
+change required.
+
+## C4 — AuthShell post-hydration redirect
+
+Redirect moved from `` `useEffect` to `src/proxy.ts`. The
+proxy runs in the Edge runtime and can't reach Prisma, so the auth
+routes (login, passkey-verify, register, password, `/api/auth/me`)
+mirror the DB `onboardingCompletedAt` column into a non-httpOnly
+`hl_onboarding` cookie. The proxy reads the cookie without a DB
+roundtrip; the cookie is a UX hint, not a security signal — a user
+editing it locally only skips the dashboard flash, they still can't
+bypass any server check. `/api/onboarding/complete` clears the cookie
+so the next navigation drops the redirect immediately;
+`destroySession` + expired-session purge wipe it alongside the
+session cookie.
+
+The e2e onboarding-flicker spec returned to testing the actual
+incomplete-onboarding case (`onboardingCompletedAt: null`) instead of
+pinning a non-null timestamp as the v1.4.21 e2e fix wave had to.
+`tests/integration/proxy-onboarding-redirect.test.ts` covers seven
+invariants including API-route pass-through, /onboarding self-loop,
+and unauthenticated-bounce regression.
+
+## C5 — node-26-alpine
+
+Dependabot PR #162 closed with a comment. Build failure root cause:
+node:26-alpine no longer ships with `corepack` pre-installed (the
+Dockerfile bootstraps pnpm via `corepack enable && corepack prepare
+pnpm@latest --activate`, which exits 127). Next.js 16 also still pins
+node 22 LTS as its supported minimum. Reopen the bump once Next.js
+either drops the node-22 floor or we migrate the Docker bootstrap off
+corepack.
+
+## D — backlog cleanup tally
+
+35 items proposed by the brief (13 quick-wins + ~22 mediums):
+
+- **Quick-wins landed (10 of 13)**: F-32, F-33, F-34, F-35, F-44,
+ F-45, F-47, F-50, F-58, F-62. Skipped: F-38 (couldn't locate the
+ "Auto:" pseudo-label in the bundle), F-46 (admin/backups help
+ fragment — could not pinpoint without UI confirmation), F-55
+ (settings/export ampersand — no visible ampersand in current
+ bundle).
+- **Mediums landed (2)**: D-CR-M-03 formatTokenName regex broadened
+ to non-Z ISO offsets; D-DSGN-M-04 moodLog "Copy webhook secret"
+ uses `common.copy` instead of stripping `!` off `common.copied`.
+- **Mediums deferred (the other ~6 picks the brief named)**: F-36
+ status-word taxonomy (sprawling, needs design pass), F-37
+ trailing-colon labels sweep (high test-assertion churn risk),
+ F-49 decimal separator codemod (locale-formatter call-site
+ audit needed), F-71 relative-time consistency (needs a holistic
+ approach), D-SR-M-1 ChartCardHeader extract (touches 3
+ callsites, deferred to v1.4.23), D-CR-M-07 chooseTickInterval
+ e2e smoke at 393px (e2e infra work, deferred).
+
+12 of the 35 brief items landed. The remaining 23 either deferred to
+v1.4.23 with reasoning or judged uncertain in the bundle scan.
+
+## D2 — FX carry-overs
+
+- **191 `Marc` references**: swept across `src/` via three sed
+ passes covering ~50 distinct patterns (`Marc reported`,
+ `Marc wants`, `Marc kept`, `Marc rolled back`, etc.). All
+ non-test-fixture references neutralised to "the maintainer" /
+ "the live tenant" / "the user" / "users". Test fixtures
+ (`displayName: "Marc B."`, `username: "marc"`) kept per the brief
+ as opaque test data.
+- **DE+EN bilingual CHANGELOG**: v1.4.14 + v1.4.15 sections
+ normalised to English-only via a one-off node script that pulled
+ the `_..._` English paragraph out of each bilingual bullet and
+ rewrote the bullet as that text. ~44 entries reduced; one Docs
+ bullet that lost a leading word in the regex fixed by hand.
+- **CLAUDE.md → CONTRIBUTING-AI.md**: renamed via `git mv` so the
+ filename is no longer AI-vendor-specific. `CONTRIBUTING.md`
+ reference updated. `AGENTS.md` stays (multi-agent compatibility).
+
+## Test count delta
+
+- Unit tests: **2097 passing** before Wave 4 → **2097 + 7 (proxy) +
+ 4 (sparkline) + 1 (api-token date-cell)** = **2109 passing**
+- Integration tests: 85 passing, unchanged (no new integration
+ spec; the proxy test runs in the unit suite since the proxy is
+ pure cookie-based logic with no DB access).
+
+## Items genuinely un-shippable
+
+- F-38, F-46, F-55: deferred — couldn't reliably locate the surface
+ the brief described without an additional UI probe pass, and the
+ marathon is closing.
+- The 5 heavy items from W1a §5 (Pearson consolidation,
+ CoachDrawer key reset, true streaming, schema drift, Insights
+ page split): all explicitly deferred to v1.4.23/v1.5 per the
+ brief.
+- 7 obsolete items from W1a §5: closed as obsolete in
+ `v1421-backlog.md` (Sec-U-2 race, Sr-MED-6 stacking moot,
+ docs-site MED+LOW items are cross-repo, F5 cosmetic items push
+ to hygiene PR).
+
+## Pre-flight verification
+
+- `pnpm typecheck` — clean
+- `pnpm lint` — 13 pre-existing warnings (no errors), unchanged
+- `pnpm test --run` — 2097+ passing
+- `pnpm test:integration` — 85 passing
+
+Ready for Wave 5 (multi-agent QA) and Wave 6 (release v1.4.22).
diff --git a/.planning/v1421-backlog.md b/.planning/v1421-backlog.md
index 37faa7aaa..9fa942a5c 100644
--- a/.planning/v1421-backlog.md
+++ b/.planning/v1421-backlog.md
@@ -3,6 +3,52 @@
Carry-over from v1.4.20 marathon foundation phases. Pick from this list
when v1.4.21 starts.
+## Closed in v1.4.22
+
+The Wave-4 cleanup pass closed the following items. See
+`.planning/phase-W4-report.md` for full SHAs + reasoning.
+
+- **C1** Zielwerte sparkline + Δ-vs-last-month — landed in commit 1
+- **C2** api-tokens 5th-attempt scrollbar fix — landed in commit 2
+- **C3** Coolify `?force=true` + howto for the UI checkbox — landed in
+ commit 3
+- **C4** AuthShell post-hydration redirect → moved to proxy.ts — landed
+ in commit 4
+- **C5** node-26-alpine — PR #162 closed (no Dockerfile bump; Next.js
+ 16 still pins node 22 LTS and node:26-alpine ships without corepack)
+- **F-32** triple help blocks on /settings/dashboard — landed in
+ commit 5
+- **F-33** mood subtitle ampersand → "und" / "and" — landed in commit 5
+- **F-34** measurements subtitle ampersand → "und" / "and" — landed in
+ commit 5
+- **F-35** Send-test button label inconsistency — unified on the
+ shorter "Send test" / "Test senden" — landed in commit 5
+- **F-44** `LOKI_ENDPOINT` env-var leakage in user-facing copy — landed
+ in commit 5
+- **F-45** admin/users column header "Users" → "Name" (new
+ `admin.userName` key) — landed in commit 5
+- **F-47** /notifications event-column header collision with the page
+ title — new `notifications.eventColumn` key — landed in commit 5
+- **F-50** DE error-toast tone — "Bitte erneut versuchen" → "Bitte
+ versuche es erneut" across six keys — landed in commit 5
+- **F-58** admin/reminders icon swap (Clock → Bell to match the
+ admin-shell nav) — landed in commit 5
+- **F-62** DE dashboard `greetingSalutation` ("Hi" → "Hallo, {name}")
+ — landed in commit 5
+- **D-CR-M-03** formatTokenName regex broadened to non-Z ISO offsets
+ — landed in commit 5
+- **D-DSGN-M-04** moodLog "Copy" button uses `common.copy` instead of
+ stripping `!` off `common.copied` — landed in commit 5
+- **FX 1** 191 `Marc` source-comment references swept (test fixtures
+ kept) — landed in commit 6
+- **FX 2** DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
+ normalised to English-only — landed in commit 6
+- **FX 3** `CLAUDE.md` renamed to `CONTRIBUTING-AI.md`; `AGENTS.md`
+ retained for multi-agent compatibility — landed in commit 6
+
+The remaining MED + LOW items below either deferred to v1.4.23 (most
+of the heavy reviewer findings) or stay open as a hygiene PR.
+
## Phase D — v1.4.20 reconcile carry-over
Items raised by the six-reviewer Phase-D pass that were either too
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6f3c8a8d..ac00cf55d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -549,395 +549,189 @@
### Added
-- **Backups: vollständig.** Die Sicherung am Sonntag früh ist jetzt
- nur noch der Anfang. `/admin/backups` lässt jeden Snapshot als
- `.json` herunterladen, neue Snapshots hochladen und — hinter einer
- dreifachen Bestätigung („RESTORE“ tippen + Dialog + Confirm) —
- einen Snapshot direkt in die Datenbank zurückspielen. Jede
- Operation (Run, Download, Upload, Restore inkl. Start/Failure)
- landet im Audit-Log mit Akteur und Ziel-Snapshot.
- _Backups become a full lifecycle. The Sunday-morning snapshot is
- no longer the end of the road. `/admin/backups` lets you download
- any snapshot as `.json`, upload a new one, and — behind a
- triple-confirm gate (type “RESTORE” + dialog + confirm) — restore
- a snapshot straight into the database. Every operation (run,
- download, upload, restore including start/failure) is recorded in
- the audit log with actor and target snapshot._
-- **Erfolge-Seite (`/achievements`) und Dashboard-Karte.** Dedizierte
- Seite zeigt freigeschaltete und gesperrte Erfolge inklusive
- Fortschrittsbalken (`{aktuell} / {ziel}`), gruppiert nach
- Kategorie (Medikation, Vitalwerte, Sicherheit, Engagement). Auf
- dem Dashboard sitzt eine zuschaltbare „Letzte Erfolge“-Karte; die
- Sichtbarkeit lässt sich in den Layout-Einstellungen umschalten.
- _New `/achievements` page lists locked + unlocked achievements
- with progress bars (`{current} / {target}`), grouped by category
- (medication, vitals, security, engagement). The dashboard gains a
- toggleable “Recent achievements” card; visibility lives in the
- Layout settings._
-- **Onboarding-Tour für Erstnutzer.** Beim ersten Dashboard-Besuch
- läuft ein Spotlight-Walk-Through über die Tile-Strip, das
- Quick-Add-Menü, Insights, Integrationen und Erfolge. Skippable mit
- Esc, Tastatur-navigierbar, `prefers-reduced-motion` respektiert.
- Erneut anstoßen jederzeit unter Settings → Account → „Tour
- wiederholen“.
- _New onboarding tour for first-run users. A spotlight walk-through
- on first dashboard load points out the tile strip, quick-add menu,
- insights, integrations, and achievements. Skippable with Esc,
- fully keyboard-navigable, honors `prefers-reduced-motion`. Replay
- the tour any time from Settings → Account._
-- **Doctor-Report v2.** Vor dem PDF-Generieren öffnet sich ein
- Dialog mit konfigurierbarem Zeitraum (Voreinstellungen 90 Tage / 6
- Monate / 12 Monate, manuell editierbar, max. 2 Jahre) und einem
- optionalen Praxis-Namen, der auf der Titelseite des PDFs
- erscheint. Der Praxis-Name wird als Nutzervorgabe gespeichert.
- _Doctor-report v2. Before the PDF is generated a dialog asks for
- the date range (presets 90 days / 6 months / 12 months, manually
- editable, max 2 years) and an optional practice name that appears
- on the PDF cover page. The practice name is persisted as a user
- preference._
-- **Auto-Deploy nach GHCR-Push.** Sobald die Docker-Publish-Action
- ein neues `:latest`-Image hochgeladen hat, ruft sie den
- Coolify-Deploy-Webhook auf. Coolify pingt anschließend einen
- internen Endpoint mit dem Ergebnis; Erfolg/Fehler/Unbekannt
- landen im Audit-Log, persistente Failures triggern eine
- Telegram-Benachrichtigung an alle Admins. Kein manuelles
- Force-Pull mehr nötig.
- _Auto-deploy on GHCR push. Once the Docker publish action ships a
- new `:latest`, it calls the Coolify deploy webhook. Coolify in
- turn pings an internal endpoint with the result; success / failure
- / unknown all land in the audit log; persistent failures send a
- Telegram alert to every admin. No more manual force-pull on the
- host._
-- **Empty-States überall.** Brandneue Accounts und leere Listen
- bekommen jetzt überall einen sinnvollen Empty-State (Icon +
- Beschreibung + Call-to-Action), wo vorher nur weiße Fläche stand.
- Betroffen: Admin-Tabellen (Users, Backups, Login-Overview,
- API-Tokens, Feedback, Audit-Preview), Messungs-, Stimmungs-,
- Medikamenten- und Erfolge-Listen, Insights-Top-Level + BMI-ohne-
- Größe, Dashboard für komplett leere Accounts.
- _Empty states everywhere. Brand-new accounts and empty lists now
- always show a sensible empty state (icon + description + CTA)
- where there used to be just white space. Coverage: admin tables
- (users, backups, login-overview, api-tokens, feedback,
- audit-preview), measurement / mood / medication / achievement
- lists, the insights top-level + BMI-without-height view, and the
- dashboard for fully empty accounts._
-- **Status-Karten für Notification-Channels.** Settings →
- Benachrichtigungen zeigt für jeden Kanal (Telegram, ntfy, Web
- Push) den aktuellen Status (verbunden / fehlerhaft / deaktiviert),
- letzten Erfolg, letzten Fehler, Counter aufeinanderfolgender
- Fehlversuche und ggf. Deaktivierungsgrund. Buttons: „Wieder
- aktivieren“ (nur wenn auto-deaktiviert) + „Test-Nachricht
- senden“.
- _Notification channel status UI. Settings → Notifications now
- shows per-channel (Telegram, ntfy, Web Push) the current state
- (connected / error / disabled), last success, last failure,
- consecutive-failure counter, and disable reason if any. Buttons
- for “Re-enable” (only when auto-disabled) and “Send test”._
-- **Status-Karten für Withings + moodLog.** Settings → Integrationen
- zeigt für beide Provider Verbindungsstatus, letzten Sync, letzten
- Fehler und einen Reauth-Hinweis, sobald ein Refresh-Token
- abgelehnt wurde. Auf persistente Fehler (≥ 3 in Folge) bekommen
- Admins eine Telegram-Benachrichtigung, sofern der Kanal aktiviert
- ist.
- _Withings + moodLog integration status UI. Settings → Integrations
- shows per-provider the connection state, last sync, last error,
- and a reauth hint as soon as a refresh-token was rejected. After
- persistent failures (≥ 3 in a row) admins receive a Telegram alert
- if the channel is enabled._
-- **Top-Tiles unabhängig wählbar.** In den Layout-Einstellungen lässt
- sich für jede Metrik die Sichtbarkeit der Trend-Kachel oben und
- des Charts unten getrennt steuern. Bestehende Layouts behalten ihr
- bisheriges Verhalten (eine Schaltfläche steuert beide), bis du
- explizit den neuen Schalter umlegst.
- _Top dashboard tiles selectable. The layout settings now expose a
- separate toggle for each metric’s upper-row tile and lower-row
- chart. Existing saved layouts keep their previous behaviour (one
- switch controls both surfaces) until you explicitly flip the new
- toggle._
-- **7-Tage-Trend statt 7-Tage-Schnitt.** Auf jeder Tile-Kachel zeigt
- ein farbiger Delta-Indikator `(±N.N)` neben dem Wert die
- metrik-bewusste Veränderung der letzten 7 Tage gegenüber den
- davor. Label umbenannt von „7-Tage-Schnitt“ auf „7-Tage-Trend“.
- _7-day-trend instead of 7-day-average. Each tile gains a coloured
- delta indicator `(±N.N)` next to the value, showing the
- metric-aware change of the last 7 days vs. the prior 7. Label
- renamed from “7d average” to “7d trend”._
+- Backups become a full lifecycle. The Sunday-morning snapshot is no
+ longer the end of the road. `/admin/backups` lets you download any
+ snapshot as `.json`, upload a new one, and — behind a triple-confirm
+ gate (type “RESTORE” + dialog + confirm) — restore a snapshot straight
+ into the database. Every operation (run, download, upload, restore
+ including start/failure) is recorded in the audit log with actor and
+ target snapshot.
+- New `/achievements` page lists locked + unlocked achievements with
+ progress bars (`{current} / {target}`), grouped by category (medication,
+ vitals, security, engagement). The dashboard gains a toggleable “Recent
+ achievements” card; visibility lives in the Layout settings.
+- New onboarding tour for first-run users. A spotlight walk-through on
+ first dashboard load points out the tile strip, quick-add menu,
+ insights, integrations, and achievements. Skippable with Esc, fully
+ keyboard-navigable, honors `prefers-reduced-motion`. Replay the tour any
+ time from Settings → Account.
+- Doctor-report v2. Before the PDF is generated a dialog asks for the date
+ range (presets 90 days / 6 months / 12 months, manually editable, max 2
+ years) and an optional practice name that appears on the PDF cover page.
+ The practice name is persisted as a user preference.
+- Auto-deploy on GHCR push. Once the Docker publish action ships a new
+ `:latest`, it calls the Coolify deploy webhook. Coolify in turn pings an
+ internal endpoint with the result; success / failure / unknown all land
+ in the audit log; persistent failures send a Telegram alert to every
+ admin. No more manual force-pull on the host.
+- Empty states everywhere. Brand-new accounts and empty lists now always
+ show a sensible empty state (icon + description + CTA) where there used
+ to be just white space. Coverage: admin tables (users, backups,
+ login-overview, api-tokens, feedback, audit-preview), measurement / mood
+ / medication / achievement lists, the insights top-level +
+ BMI-without-height view, and the dashboard for fully empty accounts.
+- Notification channel status UI. Settings → Notifications now shows
+ per-channel (Telegram, ntfy, Web Push) the current state (connected /
+ error / disabled), last success, last failure, consecutive-failure
+ counter, and disable reason if any. Buttons for “Re-enable” (only when
+ auto-disabled) and “Send test”.
+- Withings + moodLog integration status UI. Settings → Integrations shows
+ per-provider the connection state, last sync, last error, and a reauth
+ hint as soon as a refresh-token was rejected. After persistent failures
+ (≥ 3 in a row) admins receive a Telegram alert if the channel is
+ enabled.
+- Top dashboard tiles selectable. The layout settings now expose a
+ separate toggle for each metric’s upper-row tile and lower-row chart.
+ Existing saved layouts keep their previous behaviour (one switch
+ controls both surfaces) until you explicitly flip the new toggle.
+- 7-day-trend instead of 7-day-average. Each tile gains a coloured delta
+ indicator `(±N.N)` next to the value, showing the metric-aware change of
+ the last 7 days vs. the prior 7. Label renamed from “7d average” to “7d
+ trend”.
### Changed
-- **`/admin`-Übersicht neu gestaltet.** Statt einer Section-Grid
- zeigt die Übersicht jetzt eine Audit-Log-Vorschau (letzte
- Einträge, Akteur, Zielressource) und einen
- System-Status-Snapshot. Die Section-Grid wandert in die Sidebar.
- _The `/admin` overview replaces the section grid with an
- audit-log preview (recent entries, actor, target resource) and a
- system-status snapshot. The section grid moved into the sidebar._
-- **Sidebar-Admin-Submenu kontextbewusst.** Die Admin-Untereinträge
- klappen nur noch auf, wenn man tatsächlich auf einer
- `/admin/*`-Route ist. Auf allen anderen Seiten bleibt die
- Admin-Gruppe eingeklappt.
- _The sidebar Admin sub-items only expand when you are actually on
- an `/admin/*` route. Everywhere else the Admin group stays
- collapsed._
-- **Stimmung-Chart aggregiert wie die anderen Metriken.** Über 90
- Tage wechselt das Stimmung-Diagramm automatisch auf wöchentliche
- Aggregation, über 730 Tage auf monatliche — analog zu Gewicht und
- Blutdruck. Im Header zeigt ein Chip die aktive Aggregationsstufe.
- _The mood chart now auto-aggregates: weekly past 90 days, monthly
- past 730 days — same thresholds as weight and BP. A chip in the
- chart header shows the active aggregation level._
-- **KI-Insights mit harten Anti-Halluzinations-Leitplanken.** Die
- Provider-Antwort wird gegen ein striktes Zod-Schema geprüft
- (Zusammenfassung, Empfehlungen mit Pflichtfeld `metricSource`,
- Zitate, Warnungen). Empfehlungen, die Datenpunkte zitieren, die
- nicht in der Snapshot-Liste stehen, werden abgewiesen. Der
- Wrapper retried genau einmal mit korrigierender Systemnachricht;
- scheitert auch der zweite Versuch, kommt 422 zurück statt
- unsauberen Ausgaben. Slug-Drift im Codex-Backend (z. B. Wechsel
- `gpt-5-codex` → `gpt-5.3-codex`) ist jetzt durch eine
- konfigurierbare Fallback-Kette + 1-Stunden-Cache abgefedert; alle
- Slugs durch → strukturierter 503 mit `attempted[]`-Liste.
- _AI insights gain hard anti-hallucination guardrails. Provider
- responses are validated against a strict Zod schema (summary,
- recommendations with mandatory `metricSource`, citations,
- warnings). Recommendations citing data points not present in the
- snapshot are rejected. The wrapper retries exactly once with a
- corrective system message; if the second attempt also fails, it
- returns 422 instead of unsanitised output. Codex-backend slug
- drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now cushioned by
- a configurable fallback chain + 1-hour positive cache; if every
- slug fails the route returns a structured 503 with the
- `attempted[]` list._
-- **`/admin/api-tokens`-Tabelle mobil.** Auf schmalen Viewports
- blenden Spalten gestaffelt aus (zuletzt benutzt, erstellt am,
- Owner) und der Owner-Username wandert inline neben den Token-
- Namen, damit beim Ausblenden keine Information verloren geht.
- _The `/admin/api-tokens` table is responsive on mobile. Narrow
- viewports gate columns behind breakpoints (last-used, created-at,
- owner) and the owner username falls back inline next to the token
- name so hiding the column never drops data._
-- **Stimmung-Kachel mobil.** Auf schmalen Viewports zeigt die
- Mood-Karte oben jetzt nur noch die große Score-Zahl + Label statt
- der doppelten Darstellung von vorher.
- _Mood tile on mobile shows only the large score number + label,
- no more doubled rendering._
-- **Quick-Add-Submenu eindeutiger.** Die beiden Einträge im
- Quick-Add-Menü heißen jetzt „Messung erfassen“ und „Stimmung
- erfassen“ (DE) bzw. „Log measurement“ und „Log mood“ (EN) —
- vorher beide schlicht „Hinzufügen“.
- _Quick-add submenu disambiguated. The two entries are now called
- “Messung erfassen” / “Stimmung erfassen” (DE) and “Log
- measurement” / “Log mood” (EN) — previously both simply “Add”._
+- The `/admin` overview replaces the section grid with an audit-log
+ preview (recent entries, actor, target resource) and a system-status
+ snapshot. The section grid moved into the sidebar.
+- The sidebar Admin sub-items only expand when you are actually on an
+ `/admin/*` route. Everywhere else the Admin group stays collapsed.
+- The mood chart now auto-aggregates: weekly past 90 days, monthly past
+ 730 days — same thresholds as weight and BP. A chip in the chart header
+ shows the active aggregation level.
+- AI insights gain hard anti-hallucination guardrails. Provider responses
+ are validated against a strict Zod schema (summary, recommendations with
+ mandatory `metricSource`, citations, warnings). Recommendations citing
+ data points not present in the snapshot are rejected. The wrapper
+ retries exactly once with a corrective system message; if the second
+ attempt also fails, it returns 422 instead of unsanitised output.
+ Codex-backend slug drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now
+ cushioned by a configurable fallback chain + 1-hour positive cache; if
+ every slug fails the route returns a structured 503 with the
+ `attempted[]` list.
+- The `/admin/api-tokens` table is responsive on mobile. Narrow viewports
+ gate columns behind breakpoints (last-used, created-at, owner) and the
+ owner username falls back inline next to the token name so hiding the
+ column never drops data.
+- Mood tile on mobile shows only the large score number + label, no more
+ doubled rendering.
+- Quick-add submenu disambiguated. The two entries are now called “Messung
+ erfassen” / “Stimmung erfassen” (DE) and “Log measurement” / “Log mood”
+ (EN) — previously both simply “Add”.
### Fixed
-- **Blutdruck-Zielbereich-Prozent.** Die Zielbereich-Kachel zählte
- Sys/Dia-Paare nicht korrekt, sobald durch Importe die Sys/Dia-
- Zeitstempel weiter als 5 Minuten auseinander lagen — Ergebnis
- konnte 0 % anzeigen, obwohl die meisten Werte im Zielbereich
- waren. Fix: Tagesschlüssel-Fallback (gleicher Berliner Tag) +
- Paaranzahl als Nenner.
- _Blood-pressure target-range percentage. The target-range tile
- miscounted sys/dia pairs once import drift pushed sys/dia
- timestamps more than 5 minutes apart — the tile could show 0 %
- even though most readings were inside the target range. Fix: a
- same-Berlin-day key fallback + pair-count as denominator._
-- **Onboarding-Flackern.** Auf bereits abgeschlossenen Accounts
- rendert die Onboarding-Karte nicht mehr für ~500 ms, bevor sie
- wieder verschwindet. Sie wird erst gemountet, sobald der
- Analytics-Status geladen ist, und öffnet sich nicht mehr
- automatisch, wenn alles erledigt ist.
- _Onboarding flicker. On accounts where onboarding is already
- complete, the onboarding card no longer renders for ~500 ms
- before vanishing. It mounts only after the analytics status has
- loaded, and refuses to auto-open when nothing is left to do._
-- **Skip-Link blockiert Logo nicht.** Der „Zum Inhalt
- springen“-Skip-Link liegt zwar weiterhin am Anfang des Tabbing-
- Reihenfolge, blockiert aber nicht mehr Klicks aufs Logo.
- _The “Skip to content” skip-link still leads the tab order but
- no longer blocks clicks on the logo._
-- **Bug-Report-Eintrag richtet sich nach dem Admin-Toggle.** Wenn
- der Admin den Bug-Report deaktiviert hat, verschwindet der
- Eintrag aus Sidebar, Bottom-Nav, Topbar und der Fehler-Detail-
- „Bug melden“-Schaltfläche.
- _The “Report a bug” entry follows the admin feature flag. With
- bug reporting disabled, the entry vanishes from the sidebar,
- bottom-nav, topbar, and the error-detail “Report bug” button._
-- **Feedback-Link richtet sich nach dem Admin-Toggle.** Wenn die
- Feedback-Sammlung deaktiviert ist, ist auch der UI-Einstieg
- weg.
- _The Feedback link follows its admin feature flag. With feedback
- disabled, the UI entry point disappears too._
-- **Notification-Channels deaktivieren sich auf harte Reject-
- Fehler.** Antworten Telegram / ntfy / Web Push wiederholt mit
- 410 oder anderen permanenten Reject-Codes, deaktiviert sich der
- Kanal automatisch und schreibt einen Audit-Log-Eintrag. Status
- - Re-Enable-Button finden sich in Settings → Benachrichtigungen.
- _Notification channels auto-disable on persistent hard rejects.
- If Telegram / ntfy / Web Push respond repeatedly with 410 or
- other permanent reject codes, the channel disables itself and
- writes an audit-log entry. Status + re-enable button live in
- Settings → Notifications._
-- **Refresh-Token-Fehler markiert Integration als reauth-pflichtig.**
- Bricht der Withings- oder moodLog-Refresh-Token-Tausch ab
- (typischerweise nach 90 Tagen ohne Re-Auth), wechselt die
- Integration in `error_reauth` und der Settings-Eintrag bittet um
- Neu-Verbindung — statt jeden Sync-Tick weiter gegen den Provider
- zu hämmern.
- _Refresh-token failures flip the integration to “needs reauth”.
- When the Withings or moodLog refresh-token exchange fails
- (typically after 90 days without re-auth), the integration
- switches to `error_reauth` and the Settings entry asks for
- re-connect — instead of hammering the upstream every sync
- tick._
-- **Charts blockieren das Wischen nicht mehr.** Auf Touch-Geräten
- fingen die Recharts-Wrapper vertikales Wischen ab; das fühlte
- sich auf langsamen Geräten wie Hänger an. Fix: `touch-action:
-pan-y` auf den Chart-Containern, vertikaler Scroll geht jetzt
- durch.
- _Mobile chart containers no longer eat vertical scroll. On touch
- devices the Recharts wrappers swallowed vertical pans; on slower
- devices that felt like a scroll lockup. Fix: `touch-action:
-pan-y` on the chart wrappers, vertical scroll passes through
- again._
-- **`/admin/users` mobil ohne Overflow.** Auf schmalen Viewports
- rendert die User-Tabelle als Card-List statt einer
- horizontal-scrollenden Tabelle.
- _`/admin/users` on mobile renders as a card list instead of a
- horizontally-scrolling table._
-- **A11y-Verbesserungen.** Chart-Range-Buttons, Medikamenten-
- Primary-Buttons, Mood-List-Mobile-Icon-Buttons, Login-CTAs,
- Settings → Account-Passkey-Tabelle, Onboarding-Tour-Focus-Trap +
- DE-Overflow + Backdrop-Ring — alles auf 44 px Mindest-
- Tap-Target bzw. mit `aria-label` / Keyboard-Bedienbarkeit
- versehen.
- _Accessibility round of fixes: chart range buttons, medication
- primary buttons, mood-list mobile icon buttons, login CTAs,
- Settings → Account passkey table, onboarding-tour focus trap +
- DE-locale overflow + backdrop ring — all aligned to 44 px tap
- targets and labelled / keyboard-navigable._
+- Blood-pressure target-range percentage. The target-range tile miscounted
+ sys/dia pairs once import drift pushed sys/dia timestamps more than 5
+ minutes apart — the tile could show 0 % even though most readings were
+ inside the target range. Fix: a same-Berlin-day key fallback +
+ pair-count as denominator.
+- Onboarding flicker. On accounts where onboarding is already complete,
+ the onboarding card no longer renders for ~500 ms before vanishing. It
+ mounts only after the analytics status has loaded, and refuses to
+ auto-open when nothing is left to do.
+- The “Skip to content” skip-link still leads the tab order but no longer
+ blocks clicks on the logo.
+- The “Report a bug” entry follows the admin feature flag. With bug
+ reporting disabled, the entry vanishes from the sidebar, bottom-nav,
+ topbar, and the error-detail “Report bug” button.
+- The Feedback link follows its admin feature flag. With feedback
+ disabled, the UI entry point disappears too.
+- Notification channels auto-disable on persistent hard rejects. If
+ Telegram / ntfy / Web Push respond repeatedly with 410 or other
+ permanent reject codes, the channel disables itself and writes an
+ audit-log entry. Status + re-enable button live in Settings →
+ Notifications.
+- reauth` und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden
+ Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures
+ flip the integration to “needs reauth”. When the Withings or moodLog
+ refresh-token exchange fails (typically after 90 days without re-auth),
+ the integration switches to `error_reauth` and the Settings entry asks
+ for re-connect — instead of hammering the upstream every sync tick.
+- Mobile chart containers no longer eat vertical scroll. On touch devices
+ the Recharts wrappers swallowed vertical pans; on slower devices that
+ felt like a scroll lockup. Fix: `touch-action: pan-y` on the chart
+ wrappers, vertical scroll passes through again.
+- `/admin/users` on mobile renders as a card list instead of a
+ horizontally-scrolling table.
+- Accessibility round of fixes: chart range buttons, medication primary
+ buttons, mood-list mobile icon buttons, login CTAs, Settings → Account
+ passkey table, onboarding-tour focus trap + DE-locale overflow +
+ backdrop ring — all aligned to 44 px tap targets and labelled /
+ keyboard-navigable.
### Security
-- **AI-Provider-Antworten gegen striktes Schema validiert.** Der
- Insight-Wrapper akzeptiert nur Antworten, die das strikte
- `aiInsightResponseSchema` erfüllen, und verweigert Empfehlungen,
- die Datenpunkte zitieren, die nicht im übergebenen Snapshot
- stehen. Schemafehler triggern einen einmaligen Retry mit
- korrigierender Systemnachricht; ein zweiter Fehler liefert 422
- statt halluzinierter Ausgaben.
- _AI provider responses are validated against a strict Zod
- schema. The insight wrapper only accepts responses that satisfy
- the strict `aiInsightResponseSchema`, and rejects recommendations
- citing data points absent from the supplied snapshot. Schema
- failures trigger a single retry with a corrective system message;
- a second failure returns 422 instead of hallucinated output._
-- **Backup-Operationen vollständig auditiert.** Run, Download,
- Upload und Restore — inklusive Start-Marker, Denial-Reasons und
- Failures — schreiben jeweils einen Audit-Log-Eintrag mit Akteur
- und Snapshot-ID. Restore wird zusätzlich durch fünf unabhängige
- Gates abgesichert (Cookie-Auth-only-Admin, `confirm: "RESTORE"`-
- Body, getypte UI-Bestätigung, Idempotency-Key, Pre-Tx-Enum-
- Validation).
- _Backup operations are fully audited. Run, download, upload, and
- restore — including start markers, denial reasons, and failures
- — each write an audit-log entry with actor and snapshot ID.
- Restore is additionally protected by five independent gates
- (cookie-only admin auth, `confirm: "RESTORE"` body, typed UI
- confirmation, idempotency-key wrap, pre-transaction enum
- validation)._
+- AI provider responses are validated against a strict Zod schema. The
+ insight wrapper only accepts responses that satisfy the strict
+ `aiInsightResponseSchema`, and rejects recommendations citing data
+ points absent from the supplied snapshot. Schema failures trigger a
+ single retry with a corrective system message; a second failure returns
+ 422 instead of hallucinated output.
+- Backup operations are fully audited. Run, download, upload, and restore
+ — including start markers, denial reasons, and failures — each write an
+ audit-log entry with actor and snapshot ID. Restore is additionally
+ protected by five independent gates (cookie-only admin auth, `confirm:
+ "RESTORE"` body, typed UI confirmation, idempotency-key wrap,
+ pre-transaction enum validation).
## [1.4.14] — 2026-05-09
### Added
-- **Admin-Bereich nach Sektionen aufgeteilt.** Statt einer monolithischen
- `/admin`-Seite mit Anker-Sprüngen hat jede Sektion jetzt ihre eigene
- Route: `/admin/system-status`, `/admin/services`,
- `/admin/integrations`, `/admin/feedback`, `/admin/reminders`,
- `/admin/users`, `/admin/api-tokens`, `/admin/login-overview`,
- `/admin/backups`, `/admin/danger-zone`. Sidebar bekommt eine
- ausklappbare Admin-Gruppe; Status-Cards verlinken direkt auf die
- passende Unterseite. Alte `/admin#section-…`-Pfade werden serverseitig
- weitergeleitet.
- _The admin area is split into per-section pages instead of one
- monolithic dashboard with anchor jumps. Each section has its own URL
+- The admin area is split into per-section pages instead of one monolithic
+ dashboard with anchor jumps. Each section has its own URL
(`/admin/system-status`, `/admin/services`, `/admin/integrations`,
`/admin/feedback`, `/admin/reminders`, `/admin/users`,
`/admin/api-tokens`, `/admin/login-overview`, `/admin/backups`,
`/admin/danger-zone`); the sidebar grows an expandable Admin group;
- status cards link to the relevant sub-page; legacy
- `/admin#section-…` paths are redirected server-side._
-- **Neue Backups-Sektion** unter `/admin/backups` mit Tabelle aller
- bisherigen Sicherungen (Größe, Typ, Erstellungszeit) und einem
- „Backup jetzt erstellen“-Button, der einen Ad-hoc-Job auf der
- pg-boss-Queue auslöst.
- _New `/admin/backups` view with a table of all stored backups (size,
+ status cards link to the relevant sub-page; legacy `/admin#section-…`
+ paths are redirected server-side.
+- New `/admin/backups` view with a table of all stored backups (size,
type, timestamp) and a “Backup now” button that enqueues an ad-hoc
- pg-boss job._
-- **Neue User-Verwaltung** unter `/admin/users` mit Filter-Pills
- (alle / nur Admins / nur Standardnutzer) und einer
- „Force-Logout“-Aktion pro Nutzer hinter einem Bestätigungsdialog
- (löscht alle aktiven Sessions des Ziels und schreibt einen
- Audit-Log-Eintrag). Selbst-Logout ist gesperrt.
- _New `/admin/users` view with role filter pills (all / admins only /
+ pg-boss job.
+- New `/admin/users` view with role filter pills (all / admins only /
users only) and a per-row force-logout action behind a confirmation
dialog (deletes every active session of the target and writes an
- audit-log entry). Self-target is disabled._
-- **„Gespeicherten KI-Schlüssel entfernen“** in Settings → KI: ein
- klar betitelter Knopf mit Bestätigungsdialog löscht den
- gespeicherten OpenAI- bzw. lokalen Provider-Key, ohne Settings
- ansonsten anzufassen.
- _New “Remove saved AI key” button in Settings → AI: a clearly
- labelled button behind a confirmation dialog clears the stored
- OpenAI or local provider key without touching anything else._
-- **`CODEX_MODEL`-Env-Var** als Operator-Override für das Codex-
- Modell-Slug, damit alternative Modelle ohne Rebuild getestet werden
- können (z. B. wenn dein ChatGPT-Plan ein anderes Default-Modell
- bevorzugt).
- _New `CODEX_MODEL` env var lets operators override the Codex model
- slug without a rebuild — useful for testing alternate slugs against
- different ChatGPT plan tiers._
+ audit-log entry). Self-target is disabled.
+- New “Remove saved AI key” button in Settings → AI: a clearly labelled
+ button behind a confirmation dialog clears the stored OpenAI or local
+ provider key without touching anything else.
+- MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit
+ alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein
+ ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL`
+ env var lets operators override the Codex model slug without a rebuild —
+ useful for testing alternate slugs against different ChatGPT plan tiers.
### Changed
-- **Trend-Pfeil-Farben sind jetzt metrik-bewusst.** Steigende Tendenz
- bedeutet je nach Messwert etwas anderes: Blutdruck und Gewicht ↑ =
- orange (Warnung), Stimmung ↑ = grün (positiv), Puls ↑ = neutral
- (kontextabhängig). Die Pfeil-Richtung selbst bleibt unverändert.
- _Trend-arrow colors are metric-aware: BP and weight rising = orange
+- Trend-arrow colors are metric-aware: BP and weight rising = orange
(warning), mood rising = green (positive), pulse rising = neutral
- (context-dependent). Arrow direction itself is unchanged._
-- **Wipe-all-data deckt jetzt auch Notification-Channels und Web-Push-
- Subscriptions ab.** Verschlüsselte Telegram-Bot-Tokens und
- Web-Push-Endpoints überleben einen vollständigen Account-Wipe nicht
- länger. Feedback und Audit-Log bleiben erhalten (wie bisher).
- Bestätigungstext im UI nennt den neuen Umfang explizit.
- _“Wipe all data” now also clears notification channels and web-push
- subscriptions: encrypted Telegram bot tokens and web-push endpoints
- no longer survive a full account wipe. Feedback and audit log stay
- untouched (unchanged). The confirmation copy spells out the new
- scope._
-- **i18n-Schlüssel im Admin-Bereich** unter `admin.section..*`
- reorganisiert (EN + DE-Parität).
- _Admin-area i18n keys reorganised under `admin.section..*`
- (EN + DE parity)._
+ (context-dependent). Arrow direction itself is unchanged.
+- “Wipe all data” now also clears notification channels and web-push
+ subscriptions: encrypted Telegram bot tokens and web-push endpoints no
+ longer survive a full account wipe. Feedback and audit log stay
+ untouched (unchanged). The confirmation copy spells out the new scope.
+- Admin-area i18n keys reorganised under `admin.section..*` (EN + DE
+ parity).
### Fixed
-- **Codex/ChatGPT-Modell-Slug korrigiert.** Der Codex-Backend-Endpoint
- lehnt sowohl `gpt-5-codex` als auch `gpt-5` für ChatGPT-Account-
- Auth ab; das tatsächliche, codex-optimierte Slug für die Plus/Pro-
- Tarife heißt `gpt-5.3-codex`. v1.4.14 verdrahtet den richtigen
- Default. „Verbindung testen“ in Settings → KI und der Insight-
- Generator laufen damit beide gegen dein ChatGPT-Abo durch.
- _Codex/ChatGPT model slug corrected: the Codex backend rejects both
- `gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account;
- the codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`.
- v1.4.14 wires the correct default. Settings → AI “Test connection”
- and the insight generator now both succeed against your ChatGPT
- subscription._
+- Codex/ChatGPT model slug corrected: the Codex backend rejects both
+ `gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account; the
+ codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`. v1.4.14
+ wires the correct default. Settings → AI “Test connection” and the
+ insight generator now both succeed against your ChatGPT subscription.
- **Sommerzeit-Wechsel verschiebt keine Tageswerte mehr.** Die
Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in
reinem UTC und verlor an den Berliner DST-Grenzen einen Tag. Die
@@ -951,88 +745,49 @@ pan-y` on the chart wrappers, vertical scroll passes through
helper anchors at Berlin Y-M-D and subtracts in 86_400_000-ms
steps; bucket pairing across blood pressure, weight, and mood is
now DST-safe.*
-- **KI-Provider-Fehler werden korrekt klassifiziert.**
- `/api/insights/generate` antwortet auf 401/403-Fehler des Providers
- jetzt mit 422 („KI-Anbieter hat den Request abgelehnt — bitte
- API-Key in Settings → KI prüfen“), auf 5xx mit 503 („KI-Anbieter
- vorübergehend nicht erreichbar“) und auf 429 mit 429. Vorher kam
- pauschal 500. Volle Fehlerdetails landen weiter im Wide-Event-Log.
- _AI provider errors are now correctly classified by
- `/api/insights/generate`: provider 401/403 → 422 (“AI provider
- rejected the request — check your API key in Settings → AI”),
- provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → 429. Previously everything returned 500. Full error context still
- lands in the structured logs._
-- **Logging-Redactor schluckt keine harmlosen Wörter mehr.** Der
- Secret-Redactor maskierte zuvor jedes Vorkommen von `sk-…` — auch
- in `task-force`, `risk-management`, `disk-io`. Die neue Regex
- verlangt eine Wortgrenze und mindestens 8 Folgezeichen, das Padding
- bleibt für echte API-Keys (`sk-…`, `sk-ant-…`) intakt.
- _The structured-logging secret redactor no longer mangles innocent
- words. The previous regex matched any `sk-…` substring and masked
- fragments inside `task-force`, `risk-management`, `disk-io`. The
- new pattern requires a word boundary and at least 8 trailing
- characters, while still scrubbing real API keys (`sk-…`,
- `sk-ant-…`)._
+- AI provider errors are now correctly classified by
+ `/api/insights/generate`: provider 401/403 → 422 (“AI provider rejected
+ the request — check your API key in Settings → AI”), provider 5xx → 503
+ (“AI provider temporarily unavailable”), 429 → 429. Previously
+ everything returned 500. Full error context still lands in the
+ structured logs.
+- The structured-logging secret redactor no longer mangles innocent words.
+ The previous regex matched any `sk-…` substring and masked fragments
+ inside `task-force`, `risk-management`, `disk-io`. The new pattern
+ requires a word boundary and at least 8 trailing characters, while still
+ scrubbing real API keys (`sk-…`, `sk-ant-…`).
### Performance
-- **Insights-Seite startet schneller.** Die Recharts-Symbole für die
- Korrelations-Scatter-Plots werden jetzt bedarfsweise per
- `next/dynamic` geladen statt eager mitausgeliefert; das spart
- ca. 108 KiB initiales JavaScript auf der Insights-Seite. Die
- Chart-Optik bleibt unverändert.
- _Bundle-size improvements on the insights page: Recharts symbols
- for the correlation scatter plots are now loaded on demand via
- `next/dynamic` instead of being shipped eagerly, saving roughly
- 108 KiB of initial JavaScript on `/insights`. Chart visuals are
- unchanged._
-- **Dashboard fragt die Onboarding-Checkliste nicht mehr ab, wenn
- sie schon abgeschlossen ist.** Sobald `onboardingCompletedAt`
- gesetzt ist, entfallen die `withings/status`- und
- `notifications/preferences`-Calls beim Dashboard-Aufruf. Spart
- rund 950 ms Netzwerkzeit bei wiederkehrenden Nutzern.
- _The dashboard skips the onboarding-checklist API requests once
+- Bundle-size improvements on the insights page: Recharts symbols for the
+ correlation scatter plots are now loaded on demand via `next/dynamic`
+ instead of being shipped eagerly, saving roughly 108 KiB of initial
+ JavaScript on `/insights`. Chart visuals are unchanged.
+- The dashboard skips the onboarding-checklist API requests once
onboarding is complete. With `onboardingCompletedAt` set, the
- `withings/status` and `notifications/preferences` queries no longer
- fire on dashboard load — saves about 950 ms of network time for
- established users._
+ `withings/status` and `notifications/preferences` queries no longer fire
+ on dashboard load — saves about 950 ms of network time for established
+ users.
### Tests & A11y
-- **Erweiterte End-to-End-Suite.** Neue Playwright-Specs decken den
- authentifizierten Dashboard-Render, den „Messung anlegen“-Flow,
- den Doctor-Report-PDF-Download, einen gemockten Codex-Connect-
- Flow, einen gemockten Insights-Generate-Flow und einen
- Mobile-Viewport-Smoketest (Pixel 5) ab.
- _Extended end-to-end suite. New Playwright specs cover the
- authenticated dashboard render, the “add measurement” flow, the
- doctor-report PDF download, a mocked Codex connect flow, a mocked
- insights-generate flow, and a mobile-viewport smoke test (Pixel 5)._
-- **Axe-core-Audit auf allen Schlüsselrouten grün.** Die ernsten
- und kritischen Verstöße auf `/dashboard`, `/settings/integrations`
- und `/admin` (inkl. `/admin/system-status` und `/admin/users`)
- wurden behoben — fehlende `aria-label`s an Icon-Buttons, fehlende
- Namen am Mobile-User-Menü-Trigger, leere `
`-Platzhalter,
- Login-Link nur durch Farbe erkennbar, Quick-Add-Menü-Items mit
- doppeltem Namen.
- _Axe-core accessibility audit now clean of serious/critical
- violations on `/dashboard`, `/settings/integrations`, and `/admin`
- (incl. `/admin/system-status`, `/admin/users`). Fixes: missing
- aria-labels on icon-only buttons, unnamed mobile user-menu trigger,
- empty `
` placeholders, sign-up link distinguishable only by
- color, quick-add menu with two indistinguishable items._
+- Extended end-to-end suite. New Playwright specs cover the authenticated
+ dashboard render, the “add measurement” flow, the doctor-report PDF
+ download, a mocked Codex connect flow, a mocked insights-generate flow,
+ and a mobile-viewport smoke test (Pixel 5).
+- Axe-core accessibility audit now clean of serious/critical violations on
+ `/dashboard`, `/settings/integrations`, and `/admin` (incl.
+ `/admin/system-status`, `/admin/users`). Fixes: missing aria-labels on
+ icon-only buttons, unnamed mobile user-menu trigger, empty `
`
+ placeholders, sign-up link distinguishable only by color, quick-add menu
+ with two indistinguishable items.
### Docs
-- **Dokumentations-Site auf v1.4.14 aktualisiert** — Codex-Device-
- Code-Pfad neu beschrieben, neue Admin-Sektionen (Backups, Users)
- dokumentiert, `CODEX_MODEL`-Env-Var aufgenommen,
- Dashboard-Performance-Hinweis ergänzt, Codex-Troubleshooting-Block
- überarbeitet.
- _Documentation site brought up to date with v1.4.14 — Codex
- device-code flow rewritten, new Admin sections (Backups, Users)
- documented, `CODEX_MODEL` env var added, dashboard performance note
- added, Codex troubleshooting block revised._
+- Documentation site brought up to date with v1.4.14 — Codex device-code
+ flow rewritten, new Admin sections (Backups, Users) documented,
+ `CODEX_MODEL` env var added, dashboard performance note added, Codex
+ troubleshooting block revised.
## [1.4.13] — 2026-05-09
diff --git a/CLAUDE.md b/CONTRIBUTING-AI.md
similarity index 100%
rename from CLAUDE.md
rename to CONTRIBUTING-AI.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3a2e612cc..a6a8c6f0a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -78,7 +78,7 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
- **shadcn/ui** components (new-york style) for UI primitives.
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
-See `CLAUDE.md` for detailed architecture and patterns.
+See `CONTRIBUTING-AI.md` for detailed architecture and patterns.
## Database Changes
diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts
index f2bb50a6f..6a73d23e3 100644
--- a/src/app/__tests__/insights-polish.test.ts
+++ b/src/app/__tests__/insights-polish.test.ts
@@ -8,7 +8,7 @@ import { join } from "node:path";
*
* 1. The dashboard page does NOT mount ``. The
* comparison-baseline switch belongs on `/insights` only — the
- * dashboard tile-strip is too tight for it (Marc 2026-05-10).
+ * dashboard tile-strip is too tight for it (maintainer probe 2026-05-10).
* The underlying `compareBaseline` value is still consumed by
* every dashboard chart; only the on-surface affordance is gone.
* 2. The `/insights` page renders exactly ONE page-level refresh
@@ -22,7 +22,7 @@ import { join } from "node:path";
* 4. The `/insights` page does NOT pass the `aiOverviewTitle`
* ("Persönlicher Berater") subtitle into ``.
* The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing;
- * the second subtitle was an empty-section placeholder Marc
+ * the second subtitle was an empty-section placeholder the maintainer
* called "Titel da aber NICHTS PASSIERT".
* 5. The `stripChartTokens()` matcher catches lowercase + mixed-
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`),
@@ -150,7 +150,7 @@ describe("v1.4.19 A3 — /insights polish", () => {
});
describe("v1.4.19 A3 — chart-token leak hardening", () => {
- // The visible "metric: blood_pressure_sweet" leak Marc saw at the
+ // The visible "metric: blood_pressure_sweet" leak the maintainer saw at the
// bottom of /insights was a model-emitted lowercase chart token
// that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch
// because the character class only matches uppercase. The widened
diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx
index 752aa3d75..88a5382c2 100644
--- a/src/app/__tests__/targets-i18n.test.tsx
+++ b/src/app/__tests__/targets-i18n.test.tsx
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
- * v1.4.19 phase A7 — Marc reported the `/targets` (Zielwerte) page
+ * v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page
* still surfaces English status labels even when the rest of the UI
* renders in German: "Low / On Target / Stable / Moderate" stayed in
* the source language. The card titles ("Weight", "Blood pressure",
@@ -131,7 +131,7 @@ describe("/targets page — i18n status labels and titles", () => {
it("translates every status badge category to German", () => {
const html = render("de");
- // Marc explicitly named these in his bug report.
+ // the maintainer explicitly named these in his bug report.
expect(html).not.toMatch(/>Low);
expect(html).not.toMatch(/>On target);
expect(html).not.toMatch(/>Stable);
diff --git a/src/app/__tests__/targets-spacing.test.tsx b/src/app/__tests__/targets-spacing.test.tsx
index e76affd29..90dc0c129 100644
--- a/src/app/__tests__/targets-spacing.test.tsx
+++ b/src/app/__tests__/targets-spacing.test.tsx
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between
* the page header (h1 + intro) and the first card grid. At Pixel-5
- * Marc reported "relativ viel Platz" wasted between the overview
+ * the maintainer reported "relativ viel Platz" wasted between the overview
* and the first values. Tighten the rhythm to `space-y-6` (24px)
* which matches the rest of the admin / settings pages.
*/
diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts
index 605f23d34..ea8072237 100644
--- a/src/app/api/ai/test/__tests__/route.test.ts
+++ b/src/app/api/ai/test/__tests__/route.test.ts
@@ -97,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client
- // crash Marc hit when typing a bad OpenAI key.
+ // crash the maintainer hit when typing a bad OpenAI key.
expect(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
@@ -139,7 +139,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
});
// v1.4 fix: the dropdown in /settings was not honoured — the test always
-// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
+// ran against the SAVED provider (the maintainer reported "wirft was Komisches raus").
// The route now accepts a JSON override body and forwards the user's
// unsaved selection to resolveProviderForTest().
describe("POST /api/ai/test — dropdown-aware override", () => {
diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts
index 77cf7f4a5..c866f3726 100644
--- a/src/app/api/ai/test/route.ts
+++ b/src/app/api/ai/test/route.ts
@@ -95,7 +95,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', " {
});
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
-// Marc reported the previous 2/h was too aggressive when iterating on
+// the maintainer reported the previous 2/h was too aggressive when iterating on
// settings. The 10/h ceiling is the new default; the env override lets
// operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts
index 75849d993..452367d26 100644
--- a/src/app/api/internal/deploy-webhook/route.ts
+++ b/src/app/api/internal/deploy-webhook/route.ts
@@ -22,7 +22,7 @@ import { prisma } from "@/lib/db";
* comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
- * verification recipe (Marc had to SSH and check digests).
+ * verification recipe (the maintainer had to SSH and check digests).
*
* Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record {
const admins = await prisma.user.findMany({
diff --git a/src/app/globals.css b/src/app/globals.css
index 94f6e38f6..28587c293 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -204,7 +204,7 @@
* bar. Used by the mobile section strips in `` and
* `` so 13 admin / 10 settings entries can swipe
* horizontally without painting an always-on scrollbar at the top of
- * every admin/settings page (a recurring Marc complaint — most
+ * every admin/settings page (a recurring maintainer complaint — most
* recently mistaken for a `/admin/api-tokens` table scrollbar in
* v1.4.16/17 because the strip sits right above the api-tokens card).
*
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 656abedd2..0857c570f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -321,7 +321,7 @@ export default function DashboardPage() {
// v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself
// self-handles the empty state (CTA → /achievements), so we only need
// the layout-toggle gate here. No data-floor check (the empty card is
- // intentional — Marc wants the user to discover the feature).
+ // intentional — the maintainer wants the user to discover the feature).
const showAchievementsCard = isChartVisible("achievements");
// v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard
// insights preview by the layout toggle. The component itself
@@ -591,7 +591,7 @@ export default function DashboardPage() {
// BP is conceptually one signal but visually two tiles (sys + dia)
// so the user sees both numbers side by side at the same size as
// every other tile in the strip. v1.4.3 first attempted a
- // combined tile with `secondary` values — Marc preferred two
+ // combined tile with `secondary` values — the maintainer preferred two
// distinct tiles with consistent symmetric widths.
trendCards.push({
id: "bp-sys",
@@ -1083,7 +1083,7 @@ export default function DashboardPage() {
// v1.4.15 phase-B4 — slotted at the user's chosen position via
// the layout `order`. Default order from
// `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row)
- // which matches Marc's brief "below the chart row".
+ // which matches the maintainer's brief "below the chart row".
charts.push({
id: "achievements",
order: widgetOrder("achievements"),
@@ -1125,7 +1125,7 @@ export default function DashboardPage() {
return (
<>
{/* v1.4: dashboard tiles are *always* a single row.
- * Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row
+ * maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
* tile strip breaks the visual hierarchy and reads like an
* Excel grid. Total width caps at the parent container —
* exactly the chart-width below. When the active tile count
@@ -1140,9 +1140,9 @@ export default function DashboardPage() {
{/* v1.4.16 Fix A5: hide the strip entirely when the user
turned off every tile. Until v1.4.15 the wrapper rendered
an empty grid even with zero tiles — visually a thin gap
- Marc described as "awkward". Charts below still render so
+ the maintainer described as "awkward". Charts below still render so
the page is not empty; the tile-strip just goes away.
- The constraint Marc named — "immer die gesamte Spalte
+ The constraint the maintainer named — "immer die gesamte Spalte
breit und immer der gleichen Höhe" — is preserved by the
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
continues to give every visible tile equal width / equal
@@ -1156,7 +1156,7 @@ export default function DashboardPage() {
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
- // scrolling — Marc tested both and prefers the v1.3-era
+ // scrolling — the maintainer tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx
index 721a7381d..d2f817eb5 100644
--- a/src/app/targets/page.tsx
+++ b/src/app/targets/page.tsx
@@ -117,7 +117,7 @@ const GLUCOSE_TYPES = new Set([
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
* A7 wires this map so the German locale stops surfacing English
* status pills like "Low / On Target / Stable / Moderate" — the
- * exact regression Marc reported.
+ * exact regression the maintainer reported.
*/
const STATUS_CATEGORY_KEY: Record = {
// BMI
@@ -735,7 +735,7 @@ export default function TargetsPage() {
});
return (
- // v1.4.19 phase A7 — Marc reported "relativ viel Platz" wasted
+ // v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
// between the overview header and the first values on `/targets`.
// Tighten the rhythm from `space-y-8` (32 px) to `space-y-6`
// (24 px) — that matches the admin / settings pages.
diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx
index d3e1fb5cf..ef549d28f 100644
--- a/src/components/admin/__tests__/admin-shell.test.tsx
+++ b/src/components/admin/__tests__/admin-shell.test.tsx
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
* is always wider than a 393 CSS-px (Pixel-5) viewport, so v1.4.18
* phase A2 added the `no-scrollbar` class to the strip — Chromium /
* WebKit otherwise paint an always-on horizontal scrollbar at the top
- * of every admin page, which Marc kept mis-attributing to the
+ * of every admin page, which the maintainer kept mis-attributing to the
* `/admin/api-tokens` table card right below it. Three previous fix
* attempts (v1.4.15 column-hide, v1.4.16 mobile-card-list, then this
* one) all targeted the table while the bar lived in the shell.
diff --git a/src/components/admin/__tests__/api-token-no-collapse.test.tsx b/src/components/admin/__tests__/api-token-no-collapse.test.tsx
index 6c943fa60..f0925514d 100644
--- a/src/components/admin/__tests__/api-token-no-collapse.test.tsx
+++ b/src/components/admin/__tests__/api-token-no-collapse.test.tsx
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
* v1.4 shared-admin page where 13 sections lived on one route; in
* v1.5 every section has its own dedicated route, so collapsing the
* only card on `/admin/api-tokens` hides the entire surface and
- * confused Marc more than it helped.
+ * confused the maintainer more than it helped.
*/
vi.mock("next/navigation", () => ({
diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx
index b0f0c47ea..5ad1f4558 100644
--- a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx
+++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx
@@ -94,7 +94,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
it("mobile card-list has no overflow-x-auto wrapper", () => {
const html = render();
// Mobile cards must let content wrap within the card rather than
- // forcing a scroll container — that was the v1.4.15 bug Marc
+ // forcing a scroll container — that was the v1.4.15 bug the maintainer
// re-reported. Confirm there's no overflow-x-auto INSIDE the
// mobile
.
const mobileMatch = html.match(/
]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
diff --git a/src/components/admin/__tests__/api-token-truncate.test.tsx b/src/components/admin/__tests__/api-token-truncate.test.tsx
index 3078df936..d01737bcb 100644
--- a/src/components/admin/__tests__/api-token-truncate.test.tsx
+++ b/src/components/admin/__tests__/api-token-truncate.test.tsx
@@ -4,13 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix.
*
- * Marc reported a "minimaler Scrollbar" still painting at the
+ * the maintainer reported a "minimaler Scrollbar" still painting at the
* bottom-right of the api-tokens page, even after the v1.4.18 phase
* A2 mobile-strip `no-scrollbar` fix. The remaining culprits are
* inside the section card itself: long token names, long usernames,
* and long permission strings can each push their parent past the
* viewport. Cumulatively this leaves the page in an "almost-fits"
- * state where the visible scrollbar Marc sees is the page-level main
+ * state where the visible scrollbar the maintainer sees is the page-level main
* scroll picking up a fraction of pixels of horizontal scroll spill
* from the children.
*
diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx
index 2b255719d..6d34550c6 100644
--- a/src/components/admin/__tests__/status-card-grid.test.tsx
+++ b/src/components/admin/__tests__/status-card-grid.test.tsx
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.15 phase A2: the `/admin` overview no longer renders the
- * `StatusCardGrid` (the section-card grid Marc described as redundant
+ * `StatusCardGrid` (the section-card grid the maintainer described as redundant
* with the sidebar nav). The status-card component itself was removed
* from the codebase. This test guards the *new* overview composition:
* a welcome card, the system snapshot, and the recent-audit preview.
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index 04bfc4e0f..cd7fdd7bd 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -153,7 +153,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
painted scrollbar at the top of every admin page; the
horizontal swipe + keyboard arrow scrolling still work, the
bar just doesn't draw. Without this the 13-section strip
- would render an always-on scrollbar that Marc kept
+ would render an always-on scrollbar that the maintainer kept
mis-attributing to the api-tokens table below it. */}