chore: voice + filename hygiene + wave-4 state wrap

Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 21:46:32 +02:00
1 parent 1499e6f83c
commit 363e73cbf2
76 files changed
+1050 -575

No files matched your search

+30 -20
View File
@@ -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
`<td>`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)
+508
View File
@@ -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 `<TrendCard>` 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: <avg7> · 30d: <avg30>` —
`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 `<span className="text-xs text-muted">{t(
"dashboard.bpInTargetAllTimeLabel")}</span>` 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 `<TrendCard>` chrome
used everywhere else on the dashboard, so either (a) BD tile gets
a bespoke `<BpInTargetTile>` 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 `<TrendCard>` with
an optional `tertiaryAvg` slot OR (cleaner) accept a third
sub-value pair (`avgAllTime`, `avgAllTimeLabel`) and render it as
`· Allzeit: <pct> %` 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 `<TrendCard>` 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 | `<div class="hidden overflow-x-auto md:block">` (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 (`<col className="w-[12%]" />`) 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 (`<col className="w-[8%]" />`) 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 `<col>` + `<th>` + `<td>`. 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:<TYPE>` 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
<p className="text-sm leading-snug">{norm.text}</p>
```
`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
- <p className="text-sm leading-snug">{norm.text}</p>
+ <p className="text-sm leading-snug">{stripChartTokens(norm.text)}</p>
```
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
`<TargetCard>` 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 `<TargetCard>` grows a 30-day mini-sparkline beneath the range
bar (same `<HealthChart mini>` 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 `<HeroStrip>` 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 `<HealthChart types={[target.type]} mini />` inside
`<TargetCard>` 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
`<TileCompareDelta>` 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 `<TrendsRow>`
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 `<HeroStrip>` 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 `<CoachDrawer>`. ~10 lines deletion.
- **S-07** — drop the never-overridden `inputId` prop on
`<CoachInput>`. 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 `<Sheet>` 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** — `<CoachDrawer key={prefill}>` 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`
+156
View File
@@ -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 `<td>`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 `<AuthShell>` `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).
+46
View File
@@ -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
+188 -433
View File
@@ -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.<slug>.*`
reorganisiert (EN + DE-Parität).
_Admin-area i18n keys reorganised under `admin.section.<slug>.*`
(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.<slug>.*` (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 `<dd>`-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 `<dd>` 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 `<dd>`
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
View File
File renamed without changes.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -8,7 +8,7 @@ import { join } from "node:path";
*
* 1. The dashboard page does NOT mount `<CompareToggle />`. 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 `<InsightAdvisorCard>`.
* 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
+2 -2
View File
@@ -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</);
+1 -1
View File
@@ -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.
*/
+2 -2
View File
@@ -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", () => {
+1 -1
View File
@@ -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 '<', "<!DOCTYPE "`,
// which is exactly how Marc encountered this when typing a bad
// which is exactly how the maintainer encountered this when typing a bad
// OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0;
let outboundStatus: number;
@@ -2,7 +2,7 @@
* PUT /api/dashboard/chart-overlay-prefs — v1.4.18
*
* Persist a single chart's overlay-prefs (3 toggles) onto the existing
* `User.dashboardWidgetsJson` blob. Marc rejected v1.4.16's always-on
* `User.dashboardWidgetsJson` blob. the maintainer rejected v1.4.16's always-on
* chart overlays (gradient fill, personal-baseline reference line,
* target-zone shading); the new pattern is per-chart switches that the
* user opts into independently and that persist per user.
+1 -1
View File
@@ -27,7 +27,7 @@ import type { NextRequest } from "next/server";
// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
// one here makes the PUT 422 silently — the toast surfaces "Layout
// konnte nicht gespeichert werden" — and Marc's tile-toggle looks
// konnte nicht gespeichert werden" — and the user's tile-toggle looks
// like it does nothing because the save round-trip never completes.
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so
// every save attempted with the achievements widget present (i.e.
@@ -223,7 +223,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
});
// 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)", () => {
+2 -2
View File
@@ -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<string,
@@ -104,7 +104,7 @@ function hasValidSecret(request: NextRequest): boolean {
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is
* safe even on a first-deploy where Marc hasn't wired the bot yet.
* safe even on a first-deploy where the maintainer hasn't wired the bot yet.
*/
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({
+1 -1
View File
@@ -204,7 +204,7 @@
* bar. Used by the mobile section strips in `<AdminShell>` and
* `<SettingsShell>` 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).
*
+7 -7
View File
@@ -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"
+2 -2
View File
@@ -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<string, string> = {
// 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.
@@ -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.
@@ -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", () => ({
@@ -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 <ul>.
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
@@ -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.
*
@@ -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.
+1 -1
View File
@@ -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. */}
<nav
aria-label={t("admin.shell.sectionsNav")}
@@ -20,7 +20,7 @@ import { type ApiTokenInfo } from "./_shared";
*
* v1.4.19 phase A7 (4th attempt) — earlier fixes (column-hide,
* mobile card-list, mobile-strip `no-scrollbar`) cleared every
* scrollbar Marc reported except a residual painted bar he kept
* scrollbar the maintainer reported except a residual painted bar he kept
* seeing at the bottom-right. Production probe confirmed no
* remaining horizontal-overflow culprits, but the cards still
* carried `break-all` next to `truncate` (dead code; `white-space:
@@ -104,7 +104,7 @@ export function ApiTokenOverviewSection() {
// section route. The previous pattern carried a "Collapse / Expand"
// toggle as an escape hatch from the v1.4 shared admin page where
// 13 sections lived together, but on a route that only renders this
// one card the toggle hides the entire surface. Marc reported it
// one card the toggle hides the entire surface. the maintainer reported it
// as "sinnlos" — gone.
const { data: tokens, isLoading } = useQuery({
queryKey: ["admin", "tokens"],
@@ -11,7 +11,7 @@ import {
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rejected the always-on overlays from B1a (gradient, baseline,
* the maintainer rejected the always-on overlays from B1a (gradient, baseline,
* target-zone shading) and asked for a per-chart switch surface so a
* user can toggle "7-Tage-Trend / Trend-Pfeil / Zielbereich" on or off
* for each chart independently. The popover is anchored top-right of
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
/**
* v1.4.18 — Default overlay state on chart wrappers.
*
* Marc rejected the always-on personal-baseline reference line that B1a
* the maintainer rejected the always-on personal-baseline reference line that B1a
* painted on every chart. The default surface for a new chart should be
* a clean line + axes + tooltip — no baseline, no target-zone shading,
* no trend line. Overlays are user-opt-in via the per-chart settings
@@ -123,7 +123,7 @@ describe("computePersonalBaseline()", () => {
describe("<HealthChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line (clean line only)", async () => {
// v1.4.18 reverts B1a's gradient-area treatment. Marc explicitly
// v1.4.18 reverts B1a's gradient-area treatment. the maintainer explicitly
// rejected the soft-color fill below the line: the line itself is
// the chart, no painted background under it. We assert the SSR
// output ships no `<linearGradient>` defs and no `chart-gradient`
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
/**
* v1.4.18 — MoodChart clean-line revert (gradient + emoji rolled back).
*
* Marc rejected B1a's coloured area fill below the chart line and the
* the maintainer rejected B1a's coloured area fill below the chart line and the
* smiley/emoji glyphs at every data point. The line stroke + Dracula
* tokens stay; the chart now paints a clean monotone line with simple
* Recharts dots.
@@ -67,7 +67,7 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
});
it("does NOT render emoji glyphs at data points (source check)", async () => {
// Marc explicitly rejected smileys, especially in the mood chart.
// the maintainer explicitly rejected smileys, especially in the mood chart.
// Recharts does not paint <Line> dots during SSR (the dot callback
// only fires after first client render), so the emoji wouldn't
// surface in renderToStaticMarkup output anyway. Inspecting the
@@ -7,7 +7,7 @@ import { join } from "node:path";
* chart in a `<ResponsiveContainer>` whose default
* `touch-action: auto` makes the browser wait for the chart's JS
* touchmove handler before starting page scroll. On slow devices that
* causes Marc's "Scrolling-Hänger im Dashboard auf Charts".
* causes the maintainer's "Scrolling-Hänger im Dashboard auf Charts".
*
* Fix: every chart's wrapper `<div>` (the one that contains the
* `<ResponsiveContainer>`) must carry the Tailwind `touch-pan-y`
@@ -9,7 +9,7 @@ import { I18nProvider } from "@/lib/i18n/context";
* v1.4.15 Fix 4 — "7-Tage-Schnitt" → "7-Tage-Trend".
*
* The avg7 line on every dashboard tile used to read "7d: 80.5" /
* "7T: 80,5" — visually an *average*. Marc wanted it to convey
* "7T: 80,5" — visually an *average*. the maintainer wanted it to convey
* *direction* over the past week instead, so the label flips to
* "7d trend" / "7T-Trend" and a signed delta indicator paints next
* to the value with metric-aware colour:
+1 -1
View File
@@ -6,7 +6,7 @@ import { LineChart as LineChartIcon } from "lucide-react";
/**
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
*
* Per Marc's research §1A — "personal-baseline framing first" — when a
* Per the maintainer's research §1A — "personal-baseline framing first" — when a
* user has only 1-2 data points there's nothing useful to plot, so we
* paint a friendly hint instead of a near-empty canvas. The hint reuses
* the `lucide-react/LineChart` glyph the rest of the app uses for chart
@@ -19,7 +19,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rolled back B1a's always-on chart overlays (gradient fills,
* the maintainer rolled back B1a's always-on chart overlays (gradient fills,
* personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface instead. Each dashboard chart card mounts
* one of these in its top-right corner (a settings-cog dropdown) so the
@@ -30,7 +30,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
* - showTrendIndicator — the "7-day trend" overlay (moving average +
* numeric Δ chip in the header)
* - showTrendArrow — the trend regression line + arrow direction
* hint + personal-baseline reference line. Marc's rule: the mean
* hint + personal-baseline reference line. the maintainer's rule: the mean
* line only paints when a trend is being displayed, so we fold
* the baseline into this toggle.
* - showTargetRange — the target-zone shading + reference lines
+2 -2
View File
@@ -842,7 +842,7 @@ export function HealthChart({
// v1.4.16 Fix A8b: when the visible window is long (e.g. "All"
// on a multi-year account), the per-week delta becomes vanishingly
// small and rounds to ±0.0 in the formatter — Marc's complaint
// small and rounds to ±0.0 in the formatter — the maintainer's complaint
// "Wenn ich alle anklicke, dann wird einfach Null Veränderung
// angezeigt" was exactly this: a slope of ~0.5 kg/year prints
// as "+0.0 kg/week" and the user reads it as "no change". For
@@ -1194,7 +1194,7 @@ export function HealthChart({
/>
))}
{/* v1.4.18 — personal-baseline reference line is now
opt-in via the Trend toggle. Marc rejected the
opt-in via the Trend toggle. the maintainer rejected the
always-on dashed mean line; it now only paints when
the user actively shows the trend overlay (matching
his rule: "only when a trend is being displayed").
+2 -2
View File
@@ -475,7 +475,7 @@ export function MoodChart({
5: t("charts.moodLabel5"),
};
// v1.4.18 — emoji glyph map removed. Marc explicitly rejected
// v1.4.18 — emoji glyph map removed. the maintainer explicitly rejected
// smileys in the mood chart; the line now uses plain Recharts dots
// and the y-axis already labels each integer (very low / low / okay
// / good / great) so the chart is fully scannable without a glyph.
@@ -632,7 +632,7 @@ export function MoodChart({
/>
))}
{/* v1.4.18 — personal-baseline gated behind the
Trend toggle (Marc: "only when a trend is being
Trend toggle (maintainer: "only when a trend is being
displayed"). Default OFF. */}
{showTrend && personalBaseline != null && (
<ReferenceLine
+1 -1
View File
@@ -192,7 +192,7 @@ export function TrendCard({
};
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
// when the call site supplies a delta. Marc's v1.4.15 feedback —
// when the call site supplies a delta. the maintainer's v1.4.15 feedback —
// "7-Tage-Schnitt" sounds like an average, but the value next to it
// is now a TREND. Distinct keys keep the label change in i18n.
const avg7LabelKey =
@@ -266,7 +266,7 @@ describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)
});
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
// Reproduces Marc's production /insights crash on 2026-05-10:
// Reproduces the maintainer's production /insights crash on 2026-05-10:
//
// TypeError: Cannot read properties of undefined (reading 'replace')
// at stripChartTokens(insight.summary)
@@ -247,7 +247,7 @@ describe("<RecommendationCard>", () => {
/**
* v1.4.22 A6 — strip chart-tokens from the always-visible
* recommendation text. W1a §3 traced the production "metric:WEIGHT"
* leak Marc saw at the bottom of the advisor card to this exact
* leak the maintainer saw at the bottom of the advisor card to this exact
* render path: every other AI-prose surface ran through
* `stripChartTokens()`, the recommendation text didn't. Pin the
* strip so a future copy-paste regression can't reintroduce the
@@ -85,7 +85,7 @@ describe("<SidebarNav> bug-report toggle", () => {
});
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
// v1.4.16 A1: Marc reported the global sidebar expanding admin
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
// already provides per-section nav inside the page. The Admin entry
// must behave EXACTLY like the Settings entry: a single link with no
@@ -112,7 +112,7 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
expect(html).toContain('href="/admin"');
// Sub-section links must NOT appear in the global sidebar — they
// belong to `<AdminShell>`'s in-page nav. Marc reported the
// belong to `<AdminShell>`'s in-page nav. the maintainer reported the
// expansion as broken UX in v1.4.16; this guard keeps it gone.
for (const section of ADMIN_SECTIONS) {
expect(html).not.toContain(`href="/admin/${section.slug}"`);
+1 -1
View File
@@ -419,7 +419,7 @@ export function OnboardingTour({
{/* v1.4.15 H4 design: tour footer buttons reach the WCAG 2.5.5
44 px tap-target floor on mobile. The `size="sm"` default
of `h-8` clipped to ~32 px which Marc reported as too small
of `h-8` clipped to ~32 px which the maintainer reported as too small
on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
keeps the desktop visual close enough — the buttons grow
~4 px taller — while making mobile usable. */}
@@ -1,7 +1,7 @@
/**
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
*
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
* The new section replaces the v1.4.15-vintage top/bottom split (the maintainer's
* `feedback_settings_no_split.md`): a single Select picks the active
* provider; the form below is switch-rendered for that provider; the
* fallback chain at the bottom reorders / toggles entries with arrow
@@ -2,7 +2,7 @@
* v1.4.19 phase A5 — IntegrationStatusPill
*
* The single tag rendered top-right of every integration card. The
* pill is the ONLY place a connection status surfaces — Marc was
* pill is the ONLY place a connection status surfaces — the maintainer was
* staring at three- to four-fold redundancy across Withings + Mood Log
* cards in v1.4.18 and called it out. This component is reusable so
* v1.4.20 can drop the same pill on the Apple Health card.
@@ -1,7 +1,7 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
*
* Marc's verbatim concern (paraphrased):
* the maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
@@ -22,7 +22,7 @@
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. Mood Log card has the visual divider that Withings always had
* — consistency Marc explicitly called out.
* — consistency the maintainer explicitly called out.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -219,7 +219,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last attempt");
});
it("Withings reauth state surfaces 'Error — reconnect' on the pill (Marc's wording)", () => {
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
+1 -1
View File
@@ -266,7 +266,7 @@ export function AboutSection() {
that proxies through `/api/version/check-updates`. The v1.4.2
version called `api.github.com` directly from the browser, which
the production CSP blocked silently — that's why "nothing
happened" when Marc clicked the button. */}
happened" when the user clicked the button. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
+2 -2
View File
@@ -168,7 +168,7 @@ export function AiSection() {
/**
* The single dropdown-driven configuration card. Replaces the
* v1.4.15-vintage two-card layout (Codex connect at the top, personal
* provider form at the bottom) per Marc's
* provider form at the bottom) per the maintainer's
* `feedback_settings_no_split.md`: one provider Select, one matching
* form below, one fallback-chain card at the bottom.
*
@@ -389,7 +389,7 @@ function ActiveProviderSelect({
/**
* Switch-rendered provider configuration. The component is the
* "form below adapts dynamically" half of Marc's pulldown contract:
* "form below adapts dynamically" half of the maintainer's pulldown contract:
* pick Codex → see Codex form; pick OpenAI → see API-key + model
* dropdown + base-URL override. The card frame is the same in every
* branch so transitions feel like updating a single concept rather
@@ -114,7 +114,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
/**
* v1.4.15 Fix 5 — independent toggle for the *strip tile* (the upper
* row of trend cards). Until v1.4.14 a single switch controlled both
* the tile AND the chart for the same metric, which Marc found too
* the tile AND the chart for the same metric, which the maintainer found too
* coarse: he wanted a chart visible without the tile (for metrics he
* tracks without wanting the at-a-glance number) or vice versa.
*/
@@ -250,8 +250,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.15 Fix 5 — table-style header naming the two
switches. The "tile" column controls the strip tile in
the upper row; the "chart" column controls the line
chart in the lower row. Marc wanted independent control
of the two surfaces (memory feedback_dashboard_top_tiles
chart in the lower row. the maintainer wanted independent control
of the two surfaces (per feedback_dashboard_top_tiles
_selectable.md). */}
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-5" aria-hidden="true" />
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
/**
* v1.4.19 phase A5 — single status pill rendered top-right of every
* integration card (Withings, Mood Log, Apple Health…). It replaces
* the three- to four-fold redundant status surface Marc spotted in
* the three- to four-fold redundant status surface the maintainer spotted in
* v1.4.18:
*
* - top-right "Connected" badge
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* - bottom-of-card "letzter Sync" line
*
* Now there is exactly ONE place a sync status can appear on a card
* and it follows the pattern Marc liked from the Withings header: a
* and it follows the pattern the maintainer liked from the Withings header: a
* Dracula-tokenized chip with the relative time inline.
*
* The component is locale-aware (EN + DE) and mobile-safe: on a
@@ -136,7 +136,7 @@ function pillStateFor(
* sync attempt failed. The pill conveys "something is wrong"; this
* line tells the user *what* is wrong so they can act on it. Keeping
* it deliberately small (one icon + one line) so it doesn't recreate
* the v1.4.18 redundant banner Marc removed.
* the v1.4.18 redundant banner the maintainer removed.
*/
function IntegrationErrorMessage({ message }: { message: string }) {
return (
@@ -653,7 +653,7 @@ function MoodLogCard({
</p>
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(Marc explicitly called the asymmetry out). */}
(the maintainer explicitly called the asymmetry out). */}
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
@@ -736,7 +736,7 @@ function MoodLogCard({
{/* v1.4.19 A5 — The "letzter Sync" line that used to
live here is gone. The pill in the card header
already carries that information; repeating it
inside the body is the redundancy Marc flagged. The
inside the body is the redundancy the maintainer flagged. The
entry-count is the only number we still surface
because it's an integration-specific datapoint the
pill cannot convey. */}
@@ -258,7 +258,7 @@ function ChannelRow({
{/* v1.4.15 H4 design: action buttons hit the WCAG 2.5.5 44 px
floor on mobile via `min-h-11`. Settings is the most-touched
mobile surface; `size="sm"` here clips to ~32 px which Marc
mobile surface; `size="sm"` here clips to ~32 px which the maintainer
flagged as too small on the iPad/iPhone PWA shell. */}
<div className="flex flex-wrap gap-2">
{channel.state === "auto_disabled" && (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "../tabs";
describe("<TabsList>", () => {
it("clamps the y-axis so a fixed-height strip never paints a vertical scrollbar", () => {
// Marc reported a stray "mini scroll-button" on the right side of
// the maintainer reported a stray "mini scroll-button" on the right side of
// the feedback inbox tab strip (Open / Acknowledged / Resolved /
// Archived). Root cause: `overflow-x-auto` couples to `overflow-y`
// — once one axis is `auto`, the other can no longer be `visible`,
+1 -1
View File
@@ -42,7 +42,7 @@ function Tabs({
// ~1 px taller than the strip on some glyph stacks, a tiny painted
// vertical scrollbar appeared on the right edge of short strips
// (e.g. the admin feedback `Open / Acknowledged / Resolved /
// Archived` tabs Marc reported in v1.4.19). Locking the y-axis hides
// Archived` tabs the maintainer reported in v1.4.19). Locking the y-axis hides
// that bar without affecting horizontal swipe behaviour.
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
// (`focus-visible:ring-[3px]`) on triggers stays inside the
+5 -5
View File
@@ -11,7 +11,7 @@ import {
*
* Until v1.4.14 the layout schema had a single `visible` flag per
* widget that controlled BOTH the upper-row strip tile AND the lower-
* row chart for the same metric. Marc wanted them as independent
* row chart for the same metric. the maintainer wanted them as independent
* toggles so a user could keep the chart on for tracking but hide
* the tile (or vice versa). v1.4.15 introduces an optional
* `tileVisible` field; the resolver mirrors it from `visible` for
@@ -120,7 +120,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
/**
* v1.4.16 Fix A5 — top-tile selector real-fix.
*
* Marc reported the toggles in Settings → Dashboard didn't filter
* the maintainer reported the toggles in Settings → Dashboard didn't filter
* the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema
* in `src/app/api/dashboard/widgets/route.ts` was missing
* `achievements` even though the default layout has it. Every PUT
@@ -132,7 +132,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
* `DEFAULT_DASHBOARD_LAYOUT.widgets`, every consumer (settings UI,
* dashboard renderer, API schema) must support it. This test pins
* the contract by enumerating every widget the default layout
* advertises, so a future addition gets caught here before Marc
* advertises, so a future addition gets caught here before the maintainer
* finds it broken in production.
*/
describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
@@ -154,7 +154,7 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
// The API persists `serializeDashboardLayout(parsed.data)` and
// reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`.
// A bug in either step (missing widget, wrong order normalization)
// would surface here. Marc's regression: the API schema rejected
// would surface here. the maintainer's regression: the API schema rejected
// the layout *before* this round-trip even started.
const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT);
const resolved = resolveDashboardLayout(serialized);
@@ -218,7 +218,7 @@ describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => {
/**
* v1.4.18 — per-chart overlay-prefs persistence.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient, baseline,
* the maintainer reverted v1.4.16's always-on chart overlays (gradient, baseline,
* target-zone shading) and asked for per-chart switches that persist
* per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we
* stay migration-free, mirroring the B8 comparisonBaseline pattern.
+1 -1
View File
@@ -104,7 +104,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
});
});
// v1.4.16 A8a: Marc spotted "Nrnberg" rendered in /admin/login-overview
// v1.4.16 A8a: the maintainer spotted "Nrnberg" rendered in /admin/login-overview
// for an audit row that should have read "Nürnberg, DE". The geo source
// (ipwho.is) returns valid UTF-8, but `Response.json()` defers its
// charset decoding to whatever the server's `Content-Type` header
@@ -12,7 +12,7 @@ import { MockAIProvider } from "../mock-client";
/**
* Phase C1 — citation-from-data hard requirement.
*
* Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
* the maintainer, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
* medizinische Dinge." ("It must ground on medical facts / user data.")
*
* Operational meaning:
@@ -10,7 +10,7 @@ import {
/**
* Phase C1 — slug-drift defence.
*
* Marc, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko
* the maintainer, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko
* darf halt immer überhaupt nicht sein."
* ("Slug-drift risk must never happen.")
*
@@ -10,7 +10,7 @@ import { MockAIProvider } from "../mock-client";
* recommendation and overwrites `rec.confidence` with the value
* `computeConfidence()` produces.
*
* Default-on per research §7 question 1 (Marc-acked). The override
* Default-on per research §7 question 1 (maintainer-acked). The override
* happens whether or not the caller supplies a bucket-series-aware
* resolver — when the resolver is missing we fall back to inputs
* derivable from the parsed payload alone (`metricSource.n`,
@@ -128,8 +128,8 @@ describe("isLegacyInsightPayload()", () => {
expect(isLegacyInsightPayload({ summary: "x" })).toBe(false);
});
it("returns true for the v1.4.14 pre-strict-schema shape (Marc's prod blob)", () => {
// The actual cached blob that crashed /insights for Marc on
it("returns true for the v1.4.14 pre-strict-schema shape (the maintainer's prod blob)", () => {
// The actual cached blob that crashed /insights for the live tenant on
// 2026-05-10. No `summary`, no `recommendations[]` — just the
// pre-v1.4.16 `{changed, stable, drivers, nextSteps, confidence,
// limitations}` shape. The route's `safeParse` failed and fell
@@ -7,7 +7,7 @@ import {
/**
* v1.4.19 phase A4 — strip the default-positivity opener.
*
* Marc reported every insight summary started with the same
* the maintainer reported every insight summary started with the same
* "Datengrundlage ist sehr stark" / "Your data foundation is strong"
* filler sentence. The user does not see what fields the snapshot
* carries, so this opener reads as awkward filler.
+1 -1
View File
@@ -2,7 +2,7 @@
* v1.4.16 phase B5d — deterministic confidence-score computation per
* recommendation.
*
* Marc's mandate: the model's self-reported confidence is unreliable
* the maintainer's mandate: the model's self-reported confidence is unreliable
* (calibrated probabilities are not a small-LLM strength), so the
* wrapper OVERRIDES with a server-computed value derived from three
* inputs that the cited data window actually exposes:
+1 -1
View File
@@ -14,7 +14,7 @@ import { annotate } from "@/lib/logging/context";
/**
* Schema-enforced wrapper around `AIProvider.generateCompletion()`.
*
* Phase C1 (v1.4.15): Marc's mandate ("zero hallucinations, must
* Phase C1 (v1.4.15): the maintainer's mandate ("zero hallucinations, must
* ground on user data") means a free-form LLM response is no longer
* acceptable. This wrapper:
*
+1 -1
View File
@@ -24,7 +24,7 @@
* - Non-object / null / undefined input → false (nothing to migrate).
* - **v1.4.14 pre-strict shape** (`{changed, stable, drivers, ...}` —
* no `summary`, no `recommendations[]`, no `findings[]`): true. This
* is the production crash Marc hit on 2026-05-10 — the route's
* is the production crash the maintainer hit on 2026-05-10 — the route's
* `safeParse` failed against `insightResultSchema`, fell through to
* the raw blob, and the rich card called `.replace()` on
* `undefined`. Detect by absence of *both* `summary` (string) and
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Scope-hardened system prompt for AI insights — Phase C1 (v1.4.15).
*
* Marc, verbatim 2026-05-09:
* the maintainer, verbatim 2026-05-09:
* "Es darf null Halluzinationen haben und es muss sich halt irgendwie
* stützen auf medizinische Dinge."
* ("Zero hallucinations. Must ground on medical facts.")
+1 -1
View File
@@ -12,7 +12,7 @@ import { annotate } from "@/lib/logging/context";
*
* The chain runner wraps `generateInsight()` with a hard-failure
* walkthrough across an ordered list of providers. The motivation is
* Marc's "ein Provider-Ausfall darf die Insights nicht zerschiessen"
* the maintainer's "ein Provider-Ausfall darf die Insights nicht zerschiessen"
* mandate: a 401 from Codex (OAuth expired), a 503 from OpenAI
* (upstream brown-out), or a network reset MUST cascade to the next
* configured provider rather than 422-ing the whole request.
+4 -4
View File
@@ -4,7 +4,7 @@ import { MEDICAL_REFERENCE_IDS } from "./medical-references";
/**
* Strict response schema for v1.4.15 AI hardening (Phase C1).
*
* Marc, verbatim 2026-05-09: "Es darf null Halluzinationen haben und es
* the maintainer, verbatim 2026-05-09: "Es darf null Halluzinationen haben und es
* muss sich halt irgendwie stützen auf medizinische Dinge." ("It must
* have zero hallucinations and must ground on medical facts.")
*
@@ -95,7 +95,7 @@ const MEDICAL_REFERENCE_ID_SET: ReadonlySet<string> = new Set(
* the rec — e.g. "+5 bpm above baseline over 7 of 7 days".
*
* Empty `comparedTo` or `deviation` is rejected so the model cannot
* emit a placeholder rationale card. Marc's mandate from B5a stays:
* emit a placeholder rationale card. the maintainer's mandate from B5a stays:
* zero hallucinations, every rec must trace back to user data.
*/
export const aiRecommendationRationaleSchema = z.object({
@@ -134,7 +134,7 @@ export const aiRecommendationSchema = z
text: z.string().min(1, "recommendation.text required"),
/**
* Mandatory citation — the data point that justified the
* recommendation. Closes the "ungrounded boilerplate" risk Marc
* recommendation. Closes the "ungrounded boilerplate" risk the maintainer
* called out: every recommendation must trace back to user data.
*/
metricSource: metricSourceSchema,
@@ -228,7 +228,7 @@ export type AIWarning = z.infer<typeof aiWarningSchema>;
* v1.4.19 (which predate the field) still parse cleanly. Fresh
* generations after the v1.4.20 PROMPT_VERSION bump emit the block.
*
* Marc's "zero hallucinations" mandate stays: the prompt instructs the
* the maintainer's "zero hallucinations" mandate stays: the prompt instructs the
* model to derive every finding from a number visible in the snapshot,
* and `keyFindings.length` is hard-capped at 5 so a runaway model
* cannot pad the surface with filler.
@@ -21,7 +21,7 @@ describe("isBpReadingInTarget()", () => {
* v1.4.16 A2 regression: 117/79 is textbook normotensive
* (well-controlled, below the goal ceiling, above hypotension). The
* v1.4.15 implementation rejected this because sys < sysLow (120),
* which collapsed Marc's BD-Zielbereich tile to 0 %. The fix uses a
* which collapsed the live tenant's BD-Zielbereich tile to 0 %. The fix uses a
* one-sided ceiling check with a clinical floor.
*/
it("counts a normotensive reading below the goal band as in-target", () => {
@@ -167,9 +167,9 @@ describe("computeBpInTargetPct", () => {
});
/**
* v1.4.16 A2 regression — Marc's actual production data.
* v1.4.16 A2 regression — the live tenant's actual production data.
*
* Marc reported BD-Zielbereich = 0 % despite multiple BP readings
* the maintainer reported BD-Zielbereich = 0 % despite multiple BP readings
* that "are clearly in target". A query against production showed
* his last 30 days of paired readings include 117/79, 122/76, 108/76,
* 106/73, 127/86, 115/78, 108/75, 124/82, 126/80, 133/95.
@@ -186,9 +186,9 @@ describe("computeBpInTargetPct", () => {
* Result: 5/10 = 50 %.
*
* Compare with the v1.4.15 narrow-band result (0/10 = 0 %) — that's
* the regression Marc reported.
* the regression the maintainer reported.
*/
it("regression: Marc's production data produces non-zero % under the ceiling semantics", () => {
it("regression: the maintainer's production data produces non-zero % under the ceiling semantics", () => {
const sys = [
reading("2026-05-08T07:38:22Z", 117),
reading("2026-05-03T21:22:02Z", 122),
@@ -237,12 +237,12 @@ describe("computeBpInTargetPct", () => {
describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
/**
* v1.4.19 A1 regression — Marc reported the BD-Zielbereich tile shows
* v1.4.19 A1 regression — the maintainer reported the BD-Zielbereich tile shows
* **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for his
* production data. Hand-counted hypothesis-1 from the brief (the
* three-way coincidence cannot be data — it's a calculation pin):
*
* - Marc has 572 paired BP readings going back to 2022-01.
* - The live tenant has 572 paired BP readings going back to 2022-01.
* - Last 7 days: 1 / 2 in target = 50 %.
* - Last 30 days: 5 / 10 in target = 50 %.
* - All time: 62 / 572 in target ≈ 11 %.
@@ -301,8 +301,8 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
expect(result.allTime).toBeNull();
});
it("regression: Marc's 30-day fixture mirrors Marc's prod headline bug", () => {
// Reuse Marc's production fixture (10 pairs, 5 in target = 50 %).
it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => {
// Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %).
// When the input is JUST those 10 pairs, allTime equals 30d == 50 %
// (this is the legitimate coincidence — it only hides the bug when
// the user has no older history). The bug surfaces the moment older
@@ -343,7 +343,7 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
describe("computeBpInTargetWindows", () => {
/**
* v1.4.18 A1 regression — Marc reported the BD-Zielbereich tile shows
* v1.4.18 A1 regression — the maintainer reported the BD-Zielbereich tile shows
* the 30-day headline (50 %) but `7T: —` and `30T: —` placeholders
* even though the user has paired BP readings in both windows. Root
* cause: the analytics route only returned `bpInTargetPct` once over
@@ -351,7 +351,7 @@ describe("computeBpInTargetWindows", () => {
* renders the "—" fallback. The fix is a windowed helper that returns
* both 7-day and 30-day shares so the tile can show all three.
*
* Pinning Marc's actual production fixture (10 paired readings over
* Pinning the live tenant's actual production fixture (10 paired readings over
* the last 30 days, 2 of them within the last 7 days) so a future
* reviewer can re-derive the expected counts by hand.
*/
@@ -432,11 +432,11 @@ describe("computeBpInTargetWindows", () => {
});
/**
* Marc's real fixture: 10 paired readings over the last 30 days (5 in
* the live tenant's real fixture: 10 paired readings over the last 30 days (5 in
* target, 5 out → 50 %), of which 2 are inside the last 7 days
* (1 in target, 1 out → 50 %).
*/
it("regression: Marc's production fixture renders 7d=50% and 30d=50%", () => {
it("regression: the maintainer's production fixture renders 7d=50% and 30d=50%", () => {
const fixtureNow = new Date("2026-05-09T12:00:00Z");
const sys = [
reading("2026-05-08T07:38:22Z", 117), // 7d IN
@@ -4,7 +4,7 @@ import { computeWindowTrend, SPLIT_HALF_THRESHOLD_DAYS } from "../window-trend";
/**
* v1.4.16 Fix A8b — chart trend overlay on the "All" filter.
*
* Marc's report: clicking the All range button on a multi-year metric
* Maintainer report: clicking the All range button on a multi-year metric
* displays "+0.0 kg/Woche" because the per-week delta rounds to zero
* once the window is years wide. The split-half delta keeps the trend
* legible by reporting the change between the first-half mean and the
@@ -63,7 +63,7 @@ describe("computeWindowTrend()", () => {
// per-week rate is small (~0.07 kg/week) which rounds to 0.1 in
// the 1-decimal formatter — and the test in the chart wrapper is
// about whether ANY signal survives. The split-half delta ≈ +5 kg
// is the kind of number Marc actually wants to see.
// is the kind of number the maintainer actually wants to see.
const months = 36;
const rawValues = Array.from({ length: months }, (_, i) => 80 + 0.3 * i);
const trendValues = rawValues; // perfectly linear, trend === raw
+2 -2
View File
@@ -18,7 +18,7 @@
* `sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`.
* With ESH 2023 narrow targets (Sys 120-129, Dia 70-79 for under-65)
* this collapses to 0 % for healthy normotensive users whose
* readings sit BELOW the goal band — Marc's actual data has many
* readings sit BELOW the goal band — the live tenant's actual data has many
* readings like 117/79 (textbook normotensive, well-controlled) that
* were marked OUT of target because sys < 120. Users intuitively
* consider those readings *in target* — and so does clinical
@@ -194,7 +194,7 @@ export function computeBpInTargetPct(
* - v1.4.18 A1 wired `avg7` + `avg30` from `computeBpInTargetWindows`,
* BUT the analytics route also routed the **headline**
* (`bpInTargetPct`) through `windows.last30Days?.pct` — i.e. the
* headline was a literal copy of `bpInTargetPct30d`. For Marc's prod
* headline was a literal copy of `bpInTargetPct30d`. For the live tenant's prod
* data (572 paired readings since 2022, recent 30 days = 50 %, all-time
* ≈ 11 %) the tile rendered `50 %` headline, `7T: 50, 30T: 50` and
* looked algorithmically pinned to 50/50/50. That was hypothesis-1
+2 -2
View File
@@ -3,7 +3,7 @@
*
* Backstory — v1.4.15 Fix 4 ("7-Tage-Schnitt" → "7-Tage-Trend"):
* the dashboard tiles labelled the 7-day average as "7T:" / "7d:"
* with the mean value beside it. Marc found that label unclear
* with the mean value beside it. the maintainer found that label unclear
* (sounded like an "average") when the goal of the tile is to
* convey *direction* over the past week. We re-use the existing
* 7-day linear-regression slope (already in `DataSummary.slope7`)
@@ -18,7 +18,7 @@
* slope7 is unavailable. Some metrics (mood, sleep) have <2 entries
* within the trailing 7-day window for users who don't log daily,
* which used to surface as a missing delta on the dashboard tile —
* Marc's complaint: "Bei Stimmung wird zum Beispiel kein Trend in
* the maintainer's complaint: "Bei Stimmung wird zum Beispiel kein Trend in
* Zahlen dargestellt." A 30-day slope still answers the question
* "is this metric drifting up or down?" — we just project it onto
* a 7-day window for label consistency. Returning `null` is reserved
+1 -1
View File
@@ -12,7 +12,7 @@
* for long ("All") ranges where the per-week rate
* rounds to ±0 in the 1-decimal formatter.
*
* v1.4.16 Fix A8b — Marc reported the chart trend overlay reads
* v1.4.16 Fix A8b — the maintainer reported the chart trend overlay reads
* "+0.0 kg/Woche" on the All filter, even when his weight clearly
* shifted across years. Root cause: `weeklyDelta = (last − first) / days
* × 7` is mathematically correct but visually rounds to zero whenever
+3 -3
View File
@@ -53,7 +53,7 @@ export interface DashboardWidgetConfig {
* `visible` so existing saved layouts keep their previous behaviour
* (one toggle controls both surfaces). When set explicitly the user
* can show the chart but hide the tile, or show the tile but hide
* the chart, which is what Marc asked for in the v1.4.15 follow-up
* the chart, which is what the maintainer asked for in the v1.4.15 follow-up
* (memory `feedback_dashboard_top_tiles_selectable.md`).
*/
tileVisible?: boolean;
@@ -95,7 +95,7 @@ export function isComparisonBaseline(
/**
* v1.4.18 — per-chart overlay-prefs.
*
* Marc rolled back B1a's always-on chart overlays (gradient fill,
* the maintainer rolled back B1a's always-on chart overlays (gradient fill,
* personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface so each chart can be tuned independently.
* The three toggles (7-day trend / Trend arrow / Target range) live in
@@ -193,7 +193,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// v1.4.15 phase-B4 — recent-unlocks card (dashboard surface for the
// gamification feature). Visible by default — the card is small,
// self-gates on having any achievements (otherwise it shows a brief
// "no achievements yet" + link), and Marc-asked-for-it explicitly.
// "no achievements yet" + link), and maintainer-asked-for-it explicitly.
// `tileVisible` is forced false because there is no tile surface
// for this widget; only the chart-row card.
{ id: "achievements", visible: true, tileVisible: false, order: 13 },
+1 -1
View File
@@ -15,7 +15,7 @@
* `Accept-Language: de, en;q=0.5` hint is sent so providers return
* native city names ("Nürnberg") rather than ASCII folds
* ("Nuremberg"). The previous path lost umlauts in production for at
* least one Marc-Brief login row that rendered as "Nrnberg" in
* least one maintainer-flagged login row that rendered as "Nrnberg" in
* /admin/login-overview — see `docs/audit/v1416-summary.md` once cut.
*/
@@ -59,7 +59,7 @@ describe("stripChartTokens", () => {
// v1.4.17 hotfix — defensive: reading `insight.summary` on a legacy
// cached payload (no `summary` field) returns undefined. Crashing
// with `Cannot read properties of undefined (reading 'replace')` was
// the production /insights bug Marc hit on 2026-05-10. Treat
// the production /insights bug the maintainer hit on 2026-05-10. Treat
// null/undefined as the empty string instead.
it("returns empty string for undefined input", () => {
expect(stripChartTokens(undefined)).toBe("");
+1 -1
View File
@@ -42,7 +42,7 @@ const ALLOWED_SET = new Set<string>(ALLOWED_CHART_TOKENS);
// v1.4.19 A3 — strip vs parse split. The strip-side character class
// is permissive (`[A-Za-z0-9_]+`) so model-emitted lowercase or
// snake_case tokens like `metric:blood_pressure_sweet_spot` (verbatim
// Marc 2026-05-10) get cleaved out of prose instead of leaking to the
// maintainer probe 2026-05-10) get cleaved out of prose instead of leaking to the
// DOM. The parse-side class stays uppercase-only so unrenderable /
// hallucinated tokens are dropped from the *render* path entirely;
// only the *strip* is permissive.
@@ -3,7 +3,7 @@
*
* The state-machine threshold tests live in `status.test.ts` (when
* does the alert fire?). This file proves WHAT the alert looks like
* — the message body Marc sees on Telegram when an integration
* — the message body the maintainer sees on Telegram when an integration
* starts crashing for one of his users.
*
* The formatter is a pure function so we can test deterministically
+1 -1
View File
@@ -2,7 +2,7 @@
* Per-minute host-load sampler that backs the admin /admin/system-status
* "last 2 hours" chart (v1.4.16 phase B3).
*
* Marc asked for a small at-a-glance graph above the system-status facts
* the maintainer asked for a small at-a-glance graph above the system-status facts
* grid showing CPU + memory + disk-IO so a glance at /admin tells him
* whether the apps-01 host is healthy. We considered scraping Coolify's
* Sentinel collector, but the Coolify HTTP API does not expose a clean