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 # v1.4.22 marathon — state log
Status: Wave 3 — Coach polish complete Status: Wave 4 — backlog cleanup + bug closure complete
Last update: 2026-05-10T21:00+02:00 Last update: 2026-05-10T21:45+02:00
> Previous patch: v1.4.21 live (image digest > Previous patch: v1.4.21 live (image digest
> `sha256:4e818d44702c…`, `/api/version=1.4.21`). > `sha256:4e818d44702c…`, `/api/version=1.4.21`).
@@ -119,41 +119,51 @@ rewrite needs research first.
### C1 — Zielwerte page upgrade ### 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 ### C2 — Admin / api-tokens scrollbar 5th attempt
- [ ] Live Playwright probe: which element actually overflows - [x] Live Playwright probe confirmed `whitespace-nowrap` on the date
- [ ] Patch the right layer this time `<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 ### C3 — Coolify image-digest auto-deploy
- [ ] Wire watchtower-via-Coolify path OR enable Coolify - [x] Workflow appends `?force=true` to the deploy webhook so doc-only
image-digest checkbox pushes still trigger a registry-digest check. Maintainer-side
- [ ] Validate end-to-end: tag push → fresh image deployed without toggle ("Watch image registry for new digests") documented in
host-side retag `.planning/coolify-auto-deploy-howto.md` — one UI flip.
### C4 — AuthShell post-hydration redirect flicker ### C4 — AuthShell post-hydration redirect flicker
- [ ] Move null-`onboardingCompletedAt` redirect to `proxy.ts` - [x] Redirect lives in `proxy.ts`. Auth routes mirror
server-side, OR gate dashboard render on the field `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 ### C5 — node-26-alpine bump
- [ ] Investigate the dependabot PR that failed; either patch the - [x] Dependabot PR #162 closed with reasoning. node:26-alpine ships
Dockerfile to support node 26 or close the PR with reasoning 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 ### D — `.planning/v1421-backlog.md` cleanup wave
Apply or confirm-defer every item: Apply or confirm-defer every item:
- [ ] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe) - [x] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe)
- [ ] F6 docs site audit (9 MED + 5 LOW) - [x] FX carry-overs:
- [ ] F5 best-practice (5 LOW) - [x] 191 `Marc` references swept across `src/` (test fixtures kept
- [ ] FX carry-overs: as opaque test data per brief)
- [ ] 191 `Marc` references in `src/` source comments - [x] DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15) reduced
- [ ] DE+EN bilingual CHANGELOG entries normalised to EN to English-only
- [ ] `CLAUDE.md` + `AGENTS.md` filename rename or retire decision - [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) ### 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 Carry-over from v1.4.20 marathon foundation phases. Pick from this list
when v1.4.21 starts. 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 ## Phase D — v1.4.20 reconcile carry-over
Items raised by the six-reviewer Phase-D pass that were either too Items raised by the six-reviewer Phase-D pass that were either too
+188 -433
View File
@@ -549,395 +549,189 @@
### Added ### Added
- **Backups: vollständig.** Die Sicherung am Sonntag früh ist jetzt - Backups become a full lifecycle. The Sunday-morning snapshot is no
nur noch der Anfang. `/admin/backups` lässt jeden Snapshot als longer the end of the road. `/admin/backups` lets you download any
`.json` herunterladen, neue Snapshots hochladen und — hinter einer snapshot as `.json`, upload a new one, and — behind a triple-confirm
dreifachen Bestätigung („RESTORE“ tippen + Dialog + Confirm) — gate (type “RESTORE” + dialog + confirm) — restore a snapshot straight
einen Snapshot direkt in die Datenbank zurückspielen. Jede into the database. Every operation (run, download, upload, restore
Operation (Run, Download, Upload, Restore inkl. Start/Failure) including start/failure) is recorded in the audit log with actor and
landet im Audit-Log mit Akteur und Ziel-Snapshot. target snapshot.
_Backups become a full lifecycle. The Sunday-morning snapshot is - New `/achievements` page lists locked + unlocked achievements with
no longer the end of the road. `/admin/backups` lets you download progress bars (`{current} / {target}`), grouped by category (medication,
any snapshot as `.json`, upload a new one, and — behind a vitals, security, engagement). The dashboard gains a toggleable “Recent
triple-confirm gate (type “RESTORE” + dialog + confirm) — restore achievements” card; visibility lives in the Layout settings.
a snapshot straight into the database. Every operation (run, - New onboarding tour for first-run users. A spotlight walk-through on
download, upload, restore including start/failure) is recorded in first dashboard load points out the tile strip, quick-add menu,
the audit log with actor and target snapshot._ insights, integrations, and achievements. Skippable with Esc, fully
- **Erfolge-Seite (`/achievements`) und Dashboard-Karte.** Dedizierte keyboard-navigable, honors `prefers-reduced-motion`. Replay the tour any
Seite zeigt freigeschaltete und gesperrte Erfolge inklusive time from Settings → Account.
Fortschrittsbalken (`{aktuell} / {ziel}`), gruppiert nach - Doctor-report v2. Before the PDF is generated a dialog asks for the date
Kategorie (Medikation, Vitalwerte, Sicherheit, Engagement). Auf range (presets 90 days / 6 months / 12 months, manually editable, max 2
dem Dashboard sitzt eine zuschaltbare „Letzte Erfolge“-Karte; die years) and an optional practice name that appears on the PDF cover page.
Sichtbarkeit lässt sich in den Layout-Einstellungen umschalten. The practice name is persisted as a user preference.
_New `/achievements` page lists locked + unlocked achievements - Auto-deploy on GHCR push. Once the Docker publish action ships a new
with progress bars (`{current} / {target}`), grouped by category `:latest`, it calls the Coolify deploy webhook. Coolify in turn pings an
(medication, vitals, security, engagement). The dashboard gains a internal endpoint with the result; success / failure / unknown all land
toggleable “Recent achievements” card; visibility lives in the in the audit log; persistent failures send a Telegram alert to every
Layout settings._ admin. No more manual force-pull on the host.
- **Onboarding-Tour für Erstnutzer.** Beim ersten Dashboard-Besuch - Empty states everywhere. Brand-new accounts and empty lists now always
läuft ein Spotlight-Walk-Through über die Tile-Strip, das show a sensible empty state (icon + description + CTA) where there used
Quick-Add-Menü, Insights, Integrationen und Erfolge. Skippable mit to be just white space. Coverage: admin tables (users, backups,
Esc, Tastatur-navigierbar, `prefers-reduced-motion` respektiert. login-overview, api-tokens, feedback, audit-preview), measurement / mood
Erneut anstoßen jederzeit unter Settings → Account → „Tour / medication / achievement lists, the insights top-level +
wiederholen“. BMI-without-height view, and the dashboard for fully empty accounts.
_New onboarding tour for first-run users. A spotlight walk-through - Notification channel status UI. Settings → Notifications now shows
on first dashboard load points out the tile strip, quick-add menu, per-channel (Telegram, ntfy, Web Push) the current state (connected /
insights, integrations, and achievements. Skippable with Esc, error / disabled), last success, last failure, consecutive-failure
fully keyboard-navigable, honors `prefers-reduced-motion`. Replay counter, and disable reason if any. Buttons for “Re-enable” (only when
the tour any time from Settings → Account._ auto-disabled) and “Send test”.
- **Doctor-Report v2.** Vor dem PDF-Generieren öffnet sich ein - Withings + moodLog integration status UI. Settings → Integrations shows
Dialog mit konfigurierbarem Zeitraum (Voreinstellungen 90 Tage / 6 per-provider the connection state, last sync, last error, and a reauth
Monate / 12 Monate, manuell editierbar, max. 2 Jahre) und einem hint as soon as a refresh-token was rejected. After persistent failures
optionalen Praxis-Namen, der auf der Titelseite des PDFs (≥ 3 in a row) admins receive a Telegram alert if the channel is
erscheint. Der Praxis-Name wird als Nutzervorgabe gespeichert. enabled.
_Doctor-report v2. Before the PDF is generated a dialog asks for - Top dashboard tiles selectable. The layout settings now expose a
the date range (presets 90 days / 6 months / 12 months, manually separate toggle for each metric’s upper-row tile and lower-row chart.
editable, max 2 years) and an optional practice name that appears Existing saved layouts keep their previous behaviour (one switch
on the PDF cover page. The practice name is persisted as a user controls both surfaces) until you explicitly flip the new toggle.
preference._ - 7-day-trend instead of 7-day-average. Each tile gains a coloured delta
- **Auto-Deploy nach GHCR-Push.** Sobald die Docker-Publish-Action indicator `(±N.N)` next to the value, showing the metric-aware change of
ein neues `:latest`-Image hochgeladen hat, ruft sie den the last 7 days vs. the prior 7. Label renamed from “7d average” to “7d
Coolify-Deploy-Webhook auf. Coolify pingt anschließend einen trend”.
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”._
### Changed ### Changed
- **`/admin`-Übersicht neu gestaltet.** Statt einer Section-Grid - The `/admin` overview replaces the section grid with an audit-log
zeigt die Übersicht jetzt eine Audit-Log-Vorschau (letzte preview (recent entries, actor, target resource) and a system-status
Einträge, Akteur, Zielressource) und einen snapshot. The section grid moved into the sidebar.
System-Status-Snapshot. Die Section-Grid wandert in die Sidebar. - The sidebar Admin sub-items only expand when you are actually on an
_The `/admin` overview replaces the section grid with an `/admin/*` route. Everywhere else the Admin group stays collapsed.
audit-log preview (recent entries, actor, target resource) and a - The mood chart now auto-aggregates: weekly past 90 days, monthly past
system-status snapshot. The section grid moved into the sidebar._ 730 days — same thresholds as weight and BP. A chip in the chart header
- **Sidebar-Admin-Submenu kontextbewusst.** Die Admin-Untereinträge shows the active aggregation level.
klappen nur noch auf, wenn man tatsächlich auf einer - AI insights gain hard anti-hallucination guardrails. Provider responses
`/admin/*`-Route ist. Auf allen anderen Seiten bleibt die are validated against a strict Zod schema (summary, recommendations with
Admin-Gruppe eingeklappt. mandatory `metricSource`, citations, warnings). Recommendations citing
_The sidebar Admin sub-items only expand when you are actually on data points not present in the snapshot are rejected. The wrapper
an `/admin/*` route. Everywhere else the Admin group stays retries exactly once with a corrective system message; if the second
collapsed._ attempt also fails, it returns 422 instead of unsanitised output.
- **Stimmung-Chart aggregiert wie die anderen Metriken.** Über 90 Codex-backend slug drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now
Tage wechselt das Stimmung-Diagramm automatisch auf wöchentliche cushioned by a configurable fallback chain + 1-hour positive cache; if
Aggregation, über 730 Tage auf monatliche — analog zu Gewicht und every slug fails the route returns a structured 503 with the
Blutdruck. Im Header zeigt ein Chip die aktive Aggregationsstufe. `attempted[]` list.
_The mood chart now auto-aggregates: weekly past 90 days, monthly - The `/admin/api-tokens` table is responsive on mobile. Narrow viewports
past 730 days — same thresholds as weight and BP. A chip in the gate columns behind breakpoints (last-used, created-at, owner) and the
chart header shows the active aggregation level._ owner username falls back inline next to the token name so hiding the
- **KI-Insights mit harten Anti-Halluzinations-Leitplanken.** Die column never drops data.
Provider-Antwort wird gegen ein striktes Zod-Schema geprüft - Mood tile on mobile shows only the large score number + label, no more
(Zusammenfassung, Empfehlungen mit Pflichtfeld `metricSource`, doubled rendering.
Zitate, Warnungen). Empfehlungen, die Datenpunkte zitieren, die - Quick-add submenu disambiguated. The two entries are now called “Messung
nicht in der Snapshot-Liste stehen, werden abgewiesen. Der erfassen” / “Stimmung erfassen” (DE) and “Log measurement” / “Log mood”
Wrapper retried genau einmal mit korrigierender Systemnachricht; (EN) — previously both simply “Add”.
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”._
### Fixed ### Fixed
- **Blutdruck-Zielbereich-Prozent.** Die Zielbereich-Kachel zählte - Blood-pressure target-range percentage. The target-range tile miscounted
Sys/Dia-Paare nicht korrekt, sobald durch Importe die Sys/Dia- sys/dia pairs once import drift pushed sys/dia timestamps more than 5
Zeitstempel weiter als 5 Minuten auseinander lagen — Ergebnis minutes apart — the tile could show 0 % even though most readings were
konnte 0 % anzeigen, obwohl die meisten Werte im Zielbereich inside the target range. Fix: a same-Berlin-day key fallback +
waren. Fix: Tagesschlüssel-Fallback (gleicher Berliner Tag) + pair-count as denominator.
Paaranzahl als Nenner. - Onboarding flicker. On accounts where onboarding is already complete,
_Blood-pressure target-range percentage. The target-range tile the onboarding card no longer renders for ~500 ms before vanishing. It
miscounted sys/dia pairs once import drift pushed sys/dia mounts only after the analytics status has loaded, and refuses to
timestamps more than 5 minutes apart — the tile could show 0 % auto-open when nothing is left to do.
even though most readings were inside the target range. Fix: a - The “Skip to content” skip-link still leads the tab order but no longer
same-Berlin-day key fallback + pair-count as denominator._ blocks clicks on the logo.
- **Onboarding-Flackern.** Auf bereits abgeschlossenen Accounts - The “Report a bug” entry follows the admin feature flag. With bug
rendert die Onboarding-Karte nicht mehr für ~500 ms, bevor sie reporting disabled, the entry vanishes from the sidebar, bottom-nav,
wieder verschwindet. Sie wird erst gemountet, sobald der topbar, and the error-detail “Report bug” button.
Analytics-Status geladen ist, und öffnet sich nicht mehr - The Feedback link follows its admin feature flag. With feedback
automatisch, wenn alles erledigt ist. disabled, the UI entry point disappears too.
_Onboarding flicker. On accounts where onboarding is already - Notification channels auto-disable on persistent hard rejects. If
complete, the onboarding card no longer renders for ~500 ms Telegram / ntfy / Web Push respond repeatedly with 410 or other
before vanishing. It mounts only after the analytics status has permanent reject codes, the channel disables itself and writes an
loaded, and refuses to auto-open when nothing is left to do._ audit-log entry. Status + re-enable button live in Settings →
- **Skip-Link blockiert Logo nicht.** Der „Zum Inhalt Notifications.
springen“-Skip-Link liegt zwar weiterhin am Anfang des Tabbing- - reauth` und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden
Reihenfolge, blockiert aber nicht mehr Klicks aufs Logo. Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures
_The “Skip to content” skip-link still leads the tab order but flip the integration to “needs reauth”. When the Withings or moodLog
no longer blocks clicks on the logo._ refresh-token exchange fails (typically after 90 days without re-auth),
- **Bug-Report-Eintrag richtet sich nach dem Admin-Toggle.** Wenn the integration switches to `error_reauth` and the Settings entry asks
der Admin den Bug-Report deaktiviert hat, verschwindet der for re-connect — instead of hammering the upstream every sync tick.
Eintrag aus Sidebar, Bottom-Nav, Topbar und der Fehler-Detail- - Mobile chart containers no longer eat vertical scroll. On touch devices
„Bug melden“-Schaltfläche. the Recharts wrappers swallowed vertical pans; on slower devices that
_The “Report a bug” entry follows the admin feature flag. With felt like a scroll lockup. Fix: `touch-action: pan-y` on the chart
bug reporting disabled, the entry vanishes from the sidebar, wrappers, vertical scroll passes through again.
bottom-nav, topbar, and the error-detail “Report bug” button._ - `/admin/users` on mobile renders as a card list instead of a
- **Feedback-Link richtet sich nach dem Admin-Toggle.** Wenn die horizontally-scrolling table.
Feedback-Sammlung deaktiviert ist, ist auch der UI-Einstieg - Accessibility round of fixes: chart range buttons, medication primary
weg. buttons, mood-list mobile icon buttons, login CTAs, Settings → Account
_The Feedback link follows its admin feature flag. With feedback passkey table, onboarding-tour focus trap + DE-locale overflow +
disabled, the UI entry point disappears too._ backdrop ring — all aligned to 44 px tap targets and labelled /
- **Notification-Channels deaktivieren sich auf harte Reject- keyboard-navigable.
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._
### Security ### Security
- **AI-Provider-Antworten gegen striktes Schema validiert.** Der - AI provider responses are validated against a strict Zod schema. The
Insight-Wrapper akzeptiert nur Antworten, die das strikte insight wrapper only accepts responses that satisfy the strict
`aiInsightResponseSchema` erfüllen, und verweigert Empfehlungen, `aiInsightResponseSchema`, and rejects recommendations citing data
die Datenpunkte zitieren, die nicht im übergebenen Snapshot points absent from the supplied snapshot. Schema failures trigger a
stehen. Schemafehler triggern einen einmaligen Retry mit single retry with a corrective system message; a second failure returns
korrigierender Systemnachricht; ein zweiter Fehler liefert 422 422 instead of hallucinated output.
statt halluzinierter Ausgaben. - Backup operations are fully audited. Run, download, upload, and restore
_AI provider responses are validated against a strict Zod — including start markers, denial reasons, and failures — each write an
schema. The insight wrapper only accepts responses that satisfy audit-log entry with actor and snapshot ID. Restore is additionally
the strict `aiInsightResponseSchema`, and rejects recommendations protected by five independent gates (cookie-only admin auth, `confirm:
citing data points absent from the supplied snapshot. Schema "RESTORE"` body, typed UI confirmation, idempotency-key wrap,
failures trigger a single retry with a corrective system message; pre-transaction enum validation).
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)._
## [1.4.14] — 2026-05-09 ## [1.4.14] — 2026-05-09
### Added ### Added
- **Admin-Bereich nach Sektionen aufgeteilt.** Statt einer monolithischen - The admin area is split into per-section pages instead of one monolithic
`/admin`-Seite mit Anker-Sprüngen hat jede Sektion jetzt ihre eigene dashboard with anchor jumps. Each section has its own URL
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
(`/admin/system-status`, `/admin/services`, `/admin/integrations`, (`/admin/system-status`, `/admin/services`, `/admin/integrations`,
`/admin/feedback`, `/admin/reminders`, `/admin/users`, `/admin/feedback`, `/admin/reminders`, `/admin/users`,
`/admin/api-tokens`, `/admin/login-overview`, `/admin/backups`, `/admin/api-tokens`, `/admin/login-overview`, `/admin/backups`,
`/admin/danger-zone`); the sidebar grows an expandable Admin group; `/admin/danger-zone`); the sidebar grows an expandable Admin group;
status cards link to the relevant sub-page; legacy status cards link to the relevant sub-page; legacy `/admin#section-…`
`/admin#section-…` paths are redirected server-side._ paths are redirected server-side.
- **Neue Backups-Sektion** unter `/admin/backups` mit Tabelle aller - New `/admin/backups` view with a table of all stored backups (size,
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,
type, timestamp) and a “Backup now” button that enqueues an ad-hoc type, timestamp) and a “Backup now” button that enqueues an ad-hoc
pg-boss job._ pg-boss job.
- **Neue User-Verwaltung** unter `/admin/users` mit Filter-Pills - New `/admin/users` view with role filter pills (all / admins only /
(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 /
users only) and a per-row force-logout action behind a confirmation users only) and a per-row force-logout action behind a confirmation
dialog (deletes every active session of the target and writes an dialog (deletes every active session of the target and writes an
audit-log entry). Self-target is disabled._ audit-log entry). Self-target is disabled.
- **„Gespeicherten KI-Schlüssel entfernen“** in Settings → KI: ein - New “Remove saved AI key” button in Settings → AI: a clearly labelled
klar betitelter Knopf mit Bestätigungsdialog löscht den button behind a confirmation dialog clears the stored OpenAI or local
gespeicherten OpenAI- bzw. lokalen Provider-Key, ohne Settings provider key without touching anything else.
ansonsten anzufassen. - MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit
_New “Remove saved AI key” button in Settings → AI: a clearly alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein
labelled button behind a confirmation dialog clears the stored ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL`
OpenAI or local provider key without touching anything else._ env var lets operators override the Codex model slug without a rebuild —
- **`CODEX_MODEL`-Env-Var** als Operator-Override für das Codex- useful for testing alternate slugs against different ChatGPT plan tiers.
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 ### Changed
- **Trend-Pfeil-Farben sind jetzt metrik-bewusst.** Steigende Tendenz - Trend-arrow colors are metric-aware: BP and weight rising = orange
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
(warning), mood rising = green (positive), pulse rising = neutral (warning), mood rising = green (positive), pulse rising = neutral
(context-dependent). Arrow direction itself is unchanged._ (context-dependent). Arrow direction itself is unchanged.
- **Wipe-all-data deckt jetzt auch Notification-Channels und Web-Push- - “Wipe all data” now also clears notification channels and web-push
Subscriptions ab.** Verschlüsselte Telegram-Bot-Tokens und subscriptions: encrypted Telegram bot tokens and web-push endpoints no
Web-Push-Endpoints überleben einen vollständigen Account-Wipe nicht longer survive a full account wipe. Feedback and audit log stay
länger. Feedback und Audit-Log bleiben erhalten (wie bisher). untouched (unchanged). The confirmation copy spells out the new scope.
Bestätigungstext im UI nennt den neuen Umfang explizit. - Admin-area i18n keys reorganised under `admin.section.<slug>.*` (EN + DE
_“Wipe all data” now also clears notification channels and web-push parity).
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)._
### Fixed ### Fixed
- **Codex/ChatGPT-Modell-Slug korrigiert.** Der Codex-Backend-Endpoint - Codex/ChatGPT model slug corrected: the Codex backend rejects both
lehnt sowohl `gpt-5-codex` als auch `gpt-5` für ChatGPT-Account- `gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account; the
Auth ab; das tatsächliche, codex-optimierte Slug für die Plus/Pro- codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`. v1.4.14
Tarife heißt `gpt-5.3-codex`. v1.4.14 verdrahtet den richtigen wires the correct default. Settings → AI “Test connection” and the
Default. „Verbindung testen“ in Settings → KI und der Insight- insight generator now both succeed against your ChatGPT subscription.
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._
- **Sommerzeit-Wechsel verschiebt keine Tageswerte mehr.** Die - **Sommerzeit-Wechsel verschiebt keine Tageswerte mehr.** Die
Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in
reinem UTC und verlor an den Berliner DST-Grenzen einen Tag. Die 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 helper anchors at Berlin Y-M-D and subtracts in 86_400_000-ms
steps; bucket pairing across blood pressure, weight, and mood is steps; bucket pairing across blood pressure, weight, and mood is
now DST-safe.* now DST-safe.*
- **KI-Provider-Fehler werden korrekt klassifiziert.** - AI provider errors are now correctly classified by
`/api/insights/generate` antwortet auf 401/403-Fehler des Providers `/api/insights/generate`: provider 401/403 → 422 (“AI provider rejected
jetzt mit 422 („KI-Anbieter hat den Request abgelehnt — bitte the request — check your API key in Settings → AI”), provider 5xx → 503
API-Key in Settings → KI prüfen“), auf 5xx mit 503 („KI-Anbieter (“AI provider temporarily unavailable”), 429 → 429. Previously
vorübergehend nicht erreichbar“) und auf 429 mit 429. Vorher kam everything returned 500. Full error context still lands in the
pauschal 500. Volle Fehlerdetails landen weiter im Wide-Event-Log. structured logs.
_AI provider errors are now correctly classified by - The structured-logging secret redactor no longer mangles innocent words.
`/api/insights/generate`: provider 401/403 → 422 (“AI provider The previous regex matched any `sk-…` substring and masked fragments
rejected the request — check your API key in Settings → AI”), inside `task-force`, `risk-management`, `disk-io`. The new pattern
provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → 429. Previously everything returned 500. Full error context still requires a word boundary and at least 8 trailing characters, while still
lands in the structured logs._ scrubbing real API keys (`sk-…`, `sk-ant-…`).
- **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-…`)._
### Performance ### Performance
- **Insights-Seite startet schneller.** Die Recharts-Symbole für die - Bundle-size improvements on the insights page: Recharts symbols for the
Korrelations-Scatter-Plots werden jetzt bedarfsweise per correlation scatter plots are now loaded on demand via `next/dynamic`
`next/dynamic` geladen statt eager mitausgeliefert; das spart instead of being shipped eagerly, saving roughly 108 KiB of initial
ca. 108 KiB initiales JavaScript auf der Insights-Seite. Die JavaScript on `/insights`. Chart visuals are unchanged.
Chart-Optik bleibt unverändert. - 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._
- **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
onboarding is complete. With `onboardingCompletedAt` set, the onboarding is complete. With `onboardingCompletedAt` set, the
`withings/status` and `notifications/preferences` queries no longer `withings/status` and `notifications/preferences` queries no longer fire
fire on dashboard load — saves about 950 ms of network time for on dashboard load — saves about 950 ms of network time for established
established users._ users.
### Tests & A11y ### Tests & A11y
- **Erweiterte End-to-End-Suite.** Neue Playwright-Specs decken den - Extended end-to-end suite. New Playwright specs cover the authenticated
authentifizierten Dashboard-Render, den „Messung anlegen“-Flow, dashboard render, the “add measurement” flow, the doctor-report PDF
den Doctor-Report-PDF-Download, einen gemockten Codex-Connect- download, a mocked Codex connect flow, a mocked insights-generate flow,
Flow, einen gemockten Insights-Generate-Flow und einen and a mobile-viewport smoke test (Pixel 5).
Mobile-Viewport-Smoketest (Pixel 5) ab. - Axe-core accessibility audit now clean of serious/critical violations on
_Extended end-to-end suite. New Playwright specs cover the `/dashboard`, `/settings/integrations`, and `/admin` (incl.
authenticated dashboard render, the “add measurement” flow, the `/admin/system-status`, `/admin/users`). Fixes: missing aria-labels on
doctor-report PDF download, a mocked Codex connect flow, a mocked icon-only buttons, unnamed mobile user-menu trigger, empty `<dd>`
insights-generate flow, and a mobile-viewport smoke test (Pixel 5)._ placeholders, sign-up link distinguishable only by color, quick-add menu
- **Axe-core-Audit auf allen Schlüsselrouten grün.** Die ernsten with two indistinguishable items.
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._
### Docs ### Docs
- **Dokumentations-Site auf v1.4.14 aktualisiert** — Codex-Device- - Documentation site brought up to date with v1.4.14 — Codex device-code
Code-Pfad neu beschrieben, neue Admin-Sektionen (Backups, Users) flow rewritten, new Admin sections (Backups, Users) documented,
dokumentiert, `CODEX_MODEL`-Env-Var aufgenommen, `CODEX_MODEL` env var added, dashboard performance note added, Codex
Dashboard-Performance-Hinweis ergänzt, Codex-Troubleshooting-Block troubleshooting block revised.
ü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._
## [1.4.13] — 2026-05-09 ## [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. - **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. - **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 ## Database Changes
+3 -3
View File
@@ -8,7 +8,7 @@ import { join } from "node:path";
* *
* 1. The dashboard page does NOT mount `<CompareToggle />`. The * 1. The dashboard page does NOT mount `<CompareToggle />`. The
* comparison-baseline switch belongs on `/insights` only — 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 * The underlying `compareBaseline` value is still consumed by
* every dashboard chart; only the on-surface affordance is gone. * every dashboard chart; only the on-surface affordance is gone.
* 2. The `/insights` page renders exactly ONE page-level refresh * 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` * 4. The `/insights` page does NOT pass the `aiOverviewTitle`
* ("Persönlicher Berater") subtitle into `<InsightAdvisorCard>`. * ("Persönlicher Berater") subtitle into `<InsightAdvisorCard>`.
* The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing; * 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". * called "Titel da aber NICHTS PASSIERT".
* 5. The `stripChartTokens()` matcher catches lowercase + mixed- * 5. The `stripChartTokens()` matcher catches lowercase + mixed-
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`), * 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", () => { 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 // bottom of /insights was a model-emitted lowercase chart token
// that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch // that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch
// because the character class only matches uppercase. The widened // 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"; 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 * still surfaces English status labels even when the rest of the UI
* renders in German: "Low / On Target / Stable / Moderate" stayed in * renders in German: "Low / On Target / Stable / Moderate" stayed in
* the source language. The card titles ("Weight", "Blood pressure", * 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", () => { it("translates every status badge category to German", () => {
const html = render("de"); 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(/>Low</);
expect(html).not.toMatch(/>On target</); expect(html).not.toMatch(/>On target</);
expect(html).not.toMatch(/>Stable</); 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 * 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 * 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) * and the first values. Tighten the rhythm to `space-y-6` (24px)
* which matches the rest of the admin / settings pages. * 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 // v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that // doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client // 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(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/); expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/); 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 // 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 // The route now accepts a JSON override body and forwards the user's
// unsaved selection to resolveProviderForTest(). // unsaved selection to resolveProviderForTest().
describe("POST /api/ai/test — dropdown-aware override", () => { 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 // rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the // the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`, // 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. // OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0; const status = err.httpStatus ?? 0;
let outboundStatus: number; let outboundStatus: number;
@@ -2,7 +2,7 @@
* PUT /api/dashboard/chart-overlay-prefs — v1.4.18 * PUT /api/dashboard/chart-overlay-prefs — v1.4.18
* *
* Persist a single chart's overlay-prefs (3 toggles) onto the existing * 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, * chart overlays (gradient fill, personal-baseline reference line,
* target-zone shading); the new pattern is per-chart switches that the * target-zone shading); the new pattern is per-chart switches that the
* user opts into independently and that persist per user. * 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` // Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing // iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
// one here makes the PUT 422 silently — the toast surfaces "Layout // 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. // like it does nothing because the save round-trip never completes.
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so // v1.4.16 A5 root-cause: `achievements` was absent from this enum so
// every save attempted with the achievements widget present (i.e. // 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. // 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 // 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. // operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => { 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 * comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is * 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 * 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 * Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record<string, * 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 * `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher * existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is * 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> { async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({ const admins = await prisma.user.findMany({
+1 -1
View File
@@ -204,7 +204,7 @@
* bar. Used by the mobile section strips in `<AdminShell>` and * bar. Used by the mobile section strips in `<AdminShell>` and
* `<SettingsShell>` so 13 admin / 10 settings entries can swipe * `<SettingsShell>` so 13 admin / 10 settings entries can swipe
* horizontally without painting an always-on scrollbar at the top of * 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 * recently mistaken for a `/admin/api-tokens` table scrollbar in
* v1.4.16/17 because the strip sits right above the api-tokens card). * 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 // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself
// self-handles the empty state (CTA → /achievements), so we only need // self-handles the empty state (CTA → /achievements), so we only need
// the layout-toggle gate here. No data-floor check (the empty card is // 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"); const showAchievementsCard = isChartVisible("achievements");
// v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard // v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard
// insights preview by the layout toggle. The component itself // 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) // 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 // 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 // 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. // distinct tiles with consistent symmetric widths.
trendCards.push({ trendCards.push({
id: "bp-sys", id: "bp-sys",
@@ -1083,7 +1083,7 @@ export default function DashboardPage() {
// v1.4.15 phase-B4 — slotted at the user's chosen position via // v1.4.15 phase-B4 — slotted at the user's chosen position via
// the layout `order`. Default order from // the layout `order`. Default order from
// `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) // `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({ charts.push({
id: "achievements", id: "achievements",
order: widgetOrder("achievements"), order: widgetOrder("achievements"),
@@ -1125,7 +1125,7 @@ export default function DashboardPage() {
return ( return (
<> <>
{/* v1.4: dashboard tiles are *always* a single row. {/* 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 * tile strip breaks the visual hierarchy and reads like an
* Excel grid. Total width caps at the parent container — * Excel grid. Total width caps at the parent container —
* exactly the chart-width below. When the active tile count * 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 {/* v1.4.16 Fix A5: hide the strip entirely when the user
turned off every tile. Until v1.4.15 the wrapper rendered turned off every tile. Until v1.4.15 the wrapper rendered
an empty grid even with zero tiles — visually a thin gap 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 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 breit und immer der gleichen Höhe" — is preserved by the
CSS-grid `auto-fit + minmax + auto-rows-fr` track that CSS-grid `auto-fit + minmax + auto-rows-fr` track that
continues to give every visible tile equal width / equal 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 // x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem // same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal- // 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 // wrap behaviour over the one-row scroll for the symmetry
// it preserves. // it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2" 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 * pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
* A7 wires this map so the German locale stops surfacing English * A7 wires this map so the German locale stops surfacing English
* status pills like "Low / On Target / Stable / Moderate" — the * 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> = { const STATUS_CATEGORY_KEY: Record<string, string> = {
// BMI // BMI
@@ -735,7 +735,7 @@ export default function TargetsPage() {
}); });
return ( 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`. // between the overview header and the first values on `/targets`.
// Tighten the rhythm from `space-y-8` (32 px) to `space-y-6` // Tighten the rhythm from `space-y-8` (32 px) to `space-y-6`
// (24 px) — that matches the admin / settings pages. // (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 * 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 / * phase A2 added the `no-scrollbar` class to the strip — Chromium /
* WebKit otherwise paint an always-on horizontal scrollbar at the top * 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 * `/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 * 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. * 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.4 shared-admin page where 13 sections lived on one route; in
* v1.5 every section has its own dedicated route, so collapsing the * v1.5 every section has its own dedicated route, so collapsing the
* only card on `/admin/api-tokens` hides the entire surface and * 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", () => ({ vi.mock("next/navigation", () => ({
@@ -94,7 +94,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
it("mobile card-list has no overflow-x-auto wrapper", () => { it("mobile card-list has no overflow-x-auto wrapper", () => {
const html = render(); const html = render();
// Mobile cards must let content wrap within the card rather than // 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 // re-reported. Confirm there's no overflow-x-auto INSIDE the
// mobile <ul>. // mobile <ul>.
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/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. * 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 * bottom-right of the api-tokens page, even after the v1.4.18 phase
* A2 mobile-strip `no-scrollbar` fix. The remaining culprits are * A2 mobile-strip `no-scrollbar` fix. The remaining culprits are
* inside the section card itself: long token names, long usernames, * inside the section card itself: long token names, long usernames,
* and long permission strings can each push their parent past the * and long permission strings can each push their parent past the
* viewport. Cumulatively this leaves the page in an "almost-fits" * 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 * scroll picking up a fraction of pixels of horizontal scroll spill
* from the children. * 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 * 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 * with the sidebar nav). The status-card component itself was removed
* from the codebase. This test guards the *new* overview composition: * from the codebase. This test guards the *new* overview composition:
* a welcome card, the system snapshot, and the recent-audit preview. * 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 painted scrollbar at the top of every admin page; the
horizontal swipe + keyboard arrow scrolling still work, the horizontal swipe + keyboard arrow scrolling still work, the
bar just doesn't draw. Without this the 13-section strip 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. */} mis-attributing to the api-tokens table below it. */}
<nav <nav
aria-label={t("admin.shell.sectionsNav")} 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, * v1.4.19 phase A7 (4th attempt) — earlier fixes (column-hide,
* mobile card-list, mobile-strip `no-scrollbar`) cleared every * 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 * seeing at the bottom-right. Production probe confirmed no
* remaining horizontal-overflow culprits, but the cards still * remaining horizontal-overflow culprits, but the cards still
* carried `break-all` next to `truncate` (dead code; `white-space: * 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" // section route. The previous pattern carried a "Collapse / Expand"
// toggle as an escape hatch from the v1.4 shared admin page where // 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 // 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. // as "sinnlos" — gone.
const { data: tokens, isLoading } = useQuery({ const { data: tokens, isLoading } = useQuery({
queryKey: ["admin", "tokens"], queryKey: ["admin", "tokens"],
@@ -11,7 +11,7 @@ import {
/** /**
* v1.4.18 — per-chart overlay-controls popover. * 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 * 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 * user can toggle "7-Tage-Trend / Trend-Pfeil / Zielbereich" on or off
* for each chart independently. The popover is anchored top-right of * 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. * 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 * painted on every chart. The default surface for a new chart should be
* a clean line + axes + tooltip — no baseline, no target-zone shading, * a clean line + axes + tooltip — no baseline, no target-zone shading,
* no trend line. Overlays are user-opt-in via the per-chart settings * 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", () => { describe("<HealthChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line (clean line only)", async () => { 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 // rejected the soft-color fill below the line: the line itself is
// the chart, no painted background under it. We assert the SSR // the chart, no painted background under it. We assert the SSR
// output ships no `<linearGradient>` defs and no `chart-gradient` // 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). * 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 * smiley/emoji glyphs at every data point. The line stroke + Dracula
* tokens stay; the chart now paints a clean monotone line with simple * tokens stay; the chart now paints a clean monotone line with simple
* Recharts dots. * 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 () => { 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 // Recharts does not paint <Line> dots during SSR (the dot callback
// only fires after first client render), so the emoji wouldn't // only fires after first client render), so the emoji wouldn't
// surface in renderToStaticMarkup output anyway. Inspecting the // surface in renderToStaticMarkup output anyway. Inspecting the
@@ -7,7 +7,7 @@ import { join } from "node:path";
* chart in a `<ResponsiveContainer>` whose default * chart in a `<ResponsiveContainer>` whose default
* `touch-action: auto` makes the browser wait for the chart's JS * `touch-action: auto` makes the browser wait for the chart's JS
* touchmove handler before starting page scroll. On slow devices that * 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 * Fix: every chart's wrapper `<div>` (the one that contains the
* `<ResponsiveContainer>`) must carry the Tailwind `touch-pan-y` * `<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". * v1.4.15 Fix 4 — "7-Tage-Schnitt" → "7-Tage-Trend".
* *
* The avg7 line on every dashboard tile used to read "7d: 80.5" / * 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 * *direction* over the past week instead, so the label flips to
* "7d trend" / "7T-Trend" and a signed delta indicator paints next * "7d trend" / "7T-Trend" and a signed delta indicator paints next
* to the value with metric-aware colour: * 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). * 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 * 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 * 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 * 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. * 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 * personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface instead. Each dashboard chart card mounts * 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 * 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 + * - showTrendIndicator — the "7-day trend" overlay (moving average +
* numeric Δ chip in the header) * numeric Δ chip in the header)
* - showTrendArrow — the trend regression line + arrow direction * - 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 * line only paints when a trend is being displayed, so we fold
* the baseline into this toggle. * the baseline into this toggle.
* - showTargetRange — the target-zone shading + reference lines * - 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" // 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 // 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 // "Wenn ich alle anklicke, dann wird einfach Null Veränderung
// angezeigt" was exactly this: a slope of ~0.5 kg/year prints // 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 // 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 {/* 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 always-on dashed mean line; it now only paints when
the user actively shows the trend overlay (matching the user actively shows the trend overlay (matching
his rule: "only when a trend is being displayed"). his rule: "only when a trend is being displayed").
+2 -2
View File
@@ -475,7 +475,7 @@ export function MoodChart({
5: t("charts.moodLabel5"), 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 // smileys in the mood chart; the line now uses plain Recharts dots
// and the y-axis already labels each integer (very low / low / okay // and the y-axis already labels each integer (very low / low / okay
// / good / great) so the chart is fully scannable without a glyph. // / 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 {/* 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. */} displayed"). Default OFF. */}
{showTrend && personalBaseline != null && ( {showTrend && personalBaseline != null && (
<ReferenceLine <ReferenceLine
+1 -1
View File
@@ -192,7 +192,7 @@ export function TrendCard({
}; };
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend" // 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 // "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. // is now a TREND. Distinct keys keep the label change in i18n.
const avg7LabelKey = 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)", () => { 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') // TypeError: Cannot read properties of undefined (reading 'replace')
// at stripChartTokens(insight.summary) // at stripChartTokens(insight.summary)
@@ -247,7 +247,7 @@ describe("<RecommendationCard>", () => {
/** /**
* v1.4.22 A6 — strip chart-tokens from the always-visible * v1.4.22 A6 — strip chart-tokens from the always-visible
* recommendation text. W1a §3 traced the production "metric:WEIGHT" * 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 * render path: every other AI-prose surface ran through
* `stripChartTokens()`, the recommendation text didn't. Pin the * `stripChartTokens()`, the recommendation text didn't. Pin the
* strip so a future copy-paste regression can't reintroduce 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)", () => { 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>` // sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
// already provides per-section nav inside the page. The Admin entry // already provides per-section nav inside the page. The Admin entry
// must behave EXACTLY like the Settings entry: a single link with no // 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" }); const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
expect(html).toContain('href="/admin"'); expect(html).toContain('href="/admin"');
// Sub-section links must NOT appear in the global sidebar — they // 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. // expansion as broken UX in v1.4.16; this guard keeps it gone.
for (const section of ADMIN_SECTIONS) { for (const section of ADMIN_SECTIONS) {
expect(html).not.toContain(`href="/admin/${section.slug}"`); 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 {/* 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 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) on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
keeps the desktop visual close enough — the buttons grow keeps the desktop visual close enough — the buttons grow
~4 px taller — while making mobile usable. */} ~4 px taller — while making mobile usable. */}
@@ -1,7 +1,7 @@
/** /**
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven). * 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 * `feedback_settings_no_split.md`): a single Select picks the active
* provider; the form below is switch-rendered for that provider; the * provider; the form below is switch-rendered for that provider; the
* fallback chain at the bottom reorders / toggles entries with arrow * fallback chain at the bottom reorders / toggles entries with arrow
@@ -2,7 +2,7 @@
* v1.4.19 phase A5 — IntegrationStatusPill * v1.4.19 phase A5 — IntegrationStatusPill
* *
* The single tag rendered top-right of every integration card. The * 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 * 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 * 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. * 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. * 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" * - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt" * badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container. * trio container, and the badge inside that container.
@@ -22,7 +22,7 @@
* 4. Mood Log disconnected: pill says "Not connected", no other * 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces. * status display surfaces.
* 5. Mood Log card has the visual divider that Withings always had * 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@@ -219,7 +219,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last attempt"); 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({ setIntegrationStatus({
threshold: 3, threshold: 3,
integrations: [ integrations: [
+1 -1
View File
@@ -266,7 +266,7 @@ export function AboutSection() {
that proxies through `/api/version/check-updates`. The v1.4.2 that proxies through `/api/version/check-updates`. The v1.4.2
version called `api.github.com` directly from the browser, which version called `api.github.com` directly from the browser, which
the production CSP blocked silently — that's why "nothing the production CSP blocked silently — that's why "nothing
happened" when Marc clicked the button. */} happened" when the user clicked the button. */}
{version && ( {version && (
<div className="bg-card border-border rounded-xl border p-6"> <div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2"> <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 * The single dropdown-driven configuration card. Replaces the
* v1.4.15-vintage two-card layout (Codex connect at the top, personal * 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 * `feedback_settings_no_split.md`: one provider Select, one matching
* form below, one fallback-chain card at the bottom. * form below, one fallback-chain card at the bottom.
* *
@@ -389,7 +389,7 @@ function ActiveProviderSelect({
/** /**
* Switch-rendered provider configuration. The component is the * 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 * pick Codex → see Codex form; pick OpenAI → see API-key + model
* dropdown + base-URL override. The card frame is the same in every * dropdown + base-URL override. The card frame is the same in every
* branch so transitions feel like updating a single concept rather * 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 * 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 * 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 * coarse: he wanted a chart visible without the tile (for metrics he
* tracks without wanting the at-a-glance number) or vice versa. * 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 {/* v1.4.15 Fix 5 — table-style header naming the two
switches. The "tile" column controls the strip tile in switches. The "tile" column controls the strip tile in
the upper row; the "chart" column controls the line the upper row; the "chart" column controls the line
chart in the lower row. Marc wanted independent control chart in the lower row. the maintainer wanted independent control
of the two surfaces (memory feedback_dashboard_top_tiles of the two surfaces (per feedback_dashboard_top_tiles
_selectable.md). */} _selectable.md). */}
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase"> <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" /> <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 * v1.4.19 phase A5 — single status pill rendered top-right of every
* integration card (Withings, Mood Log, Apple Health…). It replaces * 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: * v1.4.18:
* *
* - top-right "Connected" badge * - top-right "Connected" badge
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* - bottom-of-card "letzter Sync" line * - bottom-of-card "letzter Sync" line
* *
* Now there is exactly ONE place a sync status can appear on a card * 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. * Dracula-tokenized chip with the relative time inline.
* *
* The component is locale-aware (EN + DE) and mobile-safe: on a * 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 * sync attempt failed. The pill conveys "something is wrong"; this
* line tells the user *what* is wrong so they can act on it. Keeping * 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 * 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 }) { function IntegrationErrorMessage({ message }: { message: string }) {
return ( return (
@@ -653,7 +653,7 @@ function MoodLogCard({
</p> </p>
{/* v1.4.19 A5 — visual divider matches Withings for consistency {/* v1.4.19 A5 — visual divider matches Withings for consistency
(Marc explicitly called the asymmetry out). */} (the maintainer explicitly called the asymmetry out). */}
<hr <hr
data-testid="integration-card-divider" data-testid="integration-card-divider"
className="border-border/60 mt-4" className="border-border/60 mt-4"
@@ -736,7 +736,7 @@ function MoodLogCard({
{/* v1.4.19 A5 — The "letzter Sync" line that used to {/* v1.4.19 A5 — The "letzter Sync" line that used to
live here is gone. The pill in the card header live here is gone. The pill in the card header
already carries that information; repeating it 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 entry-count is the only number we still surface
because it's an integration-specific datapoint the because it's an integration-specific datapoint the
pill cannot convey. */} pill cannot convey. */}
@@ -258,7 +258,7 @@ function ChannelRow({
{/* v1.4.15 H4 design: action buttons hit the WCAG 2.5.5 44 px {/* 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 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. */} flagged as too small on the iPad/iPhone PWA shell. */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{channel.state === "auto_disabled" && ( {channel.state === "auto_disabled" && (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "../tabs";
describe("<TabsList>", () => { describe("<TabsList>", () => {
it("clamps the y-axis so a fixed-height strip never paints a vertical scrollbar", () => { 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 / // the feedback inbox tab strip (Open / Acknowledged / Resolved /
// Archived). Root cause: `overflow-x-auto` couples to `overflow-y` // Archived). Root cause: `overflow-x-auto` couples to `overflow-y`
// — once one axis is `auto`, the other can no longer be `visible`, // — 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 // ~1 px taller than the strip on some glyph stacks, a tiny painted
// vertical scrollbar appeared on the right edge of short strips // vertical scrollbar appeared on the right edge of short strips
// (e.g. the admin feedback `Open / Acknowledged / Resolved / // (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. // that bar without affecting horizontal swipe behaviour.
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring // Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
// (`focus-visible:ring-[3px]`) on triggers stays inside the // (`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 * 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- * 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 * toggles so a user could keep the chart on for tracking but hide
* the tile (or vice versa). v1.4.15 introduces an optional * the tile (or vice versa). v1.4.15 introduces an optional
* `tileVisible` field; the resolver mirrors it from `visible` for * `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. * 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 * the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema
* in `src/app/api/dashboard/widgets/route.ts` was missing * in `src/app/api/dashboard/widgets/route.ts` was missing
* `achievements` even though the default layout has it. Every PUT * `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, * `DEFAULT_DASHBOARD_LAYOUT.widgets`, every consumer (settings UI,
* dashboard renderer, API schema) must support it. This test pins * dashboard renderer, API schema) must support it. This test pins
* the contract by enumerating every widget the default layout * 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. * finds it broken in production.
*/ */
describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
@@ -154,7 +154,7 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
// The API persists `serializeDashboardLayout(parsed.data)` and // The API persists `serializeDashboardLayout(parsed.data)` and
// reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`. // reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`.
// A bug in either step (missing widget, wrong order normalization) // 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. // the layout *before* this round-trip even started.
const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT); const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT);
const resolved = resolveDashboardLayout(serialized); const resolved = resolveDashboardLayout(serialized);
@@ -218,7 +218,7 @@ describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => {
/** /**
* v1.4.18 — per-chart overlay-prefs persistence. * 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 * target-zone shading) and asked for per-chart switches that persist
* per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we * per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we
* stay migration-free, mirroring the B8 comparisonBaseline pattern. * 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 // 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 // (ipwho.is) returns valid UTF-8, but `Response.json()` defers its
// charset decoding to whatever the server's `Content-Type` header // 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. * 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.") * medizinische Dinge." ("It must ground on medical facts / user data.")
* *
* Operational meaning: * Operational meaning:
@@ -10,7 +10,7 @@ import {
/** /**
* Phase C1 — slug-drift defence. * 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." * darf halt immer überhaupt nicht sein."
* ("Slug-drift risk must never happen.") * ("Slug-drift risk must never happen.")
* *
@@ -10,7 +10,7 @@ import { MockAIProvider } from "../mock-client";
* recommendation and overwrites `rec.confidence` with the value * recommendation and overwrites `rec.confidence` with the value
* `computeConfidence()` produces. * `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 * happens whether or not the caller supplies a bucket-series-aware
* resolver — when the resolver is missing we fall back to inputs * resolver — when the resolver is missing we fall back to inputs
* derivable from the parsed payload alone (`metricSource.n`, * derivable from the parsed payload alone (`metricSource.n`,
@@ -128,8 +128,8 @@ describe("isLegacyInsightPayload()", () => {
expect(isLegacyInsightPayload({ summary: "x" })).toBe(false); expect(isLegacyInsightPayload({ summary: "x" })).toBe(false);
}); });
it("returns true for the v1.4.14 pre-strict-schema shape (Marc's prod blob)", () => { 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 Marc on // The actual cached blob that crashed /insights for the live tenant on
// 2026-05-10. No `summary`, no `recommendations[]` — just the // 2026-05-10. No `summary`, no `recommendations[]` — just the
// pre-v1.4.16 `{changed, stable, drivers, nextSteps, confidence, // pre-v1.4.16 `{changed, stable, drivers, nextSteps, confidence,
// limitations}` shape. The route's `safeParse` failed and fell // limitations}` shape. The route's `safeParse` failed and fell
@@ -7,7 +7,7 @@ import {
/** /**
* v1.4.19 phase A4 — strip the default-positivity opener. * 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" * "Datengrundlage ist sehr stark" / "Your data foundation is strong"
* filler sentence. The user does not see what fields the snapshot * filler sentence. The user does not see what fields the snapshot
* carries, so this opener reads as awkward filler. * 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 * v1.4.16 phase B5d — deterministic confidence-score computation per
* recommendation. * 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 * (calibrated probabilities are not a small-LLM strength), so the
* wrapper OVERRIDES with a server-computed value derived from three * wrapper OVERRIDES with a server-computed value derived from three
* inputs that the cited data window actually exposes: * 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()`. * 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 * ground on user data") means a free-form LLM response is no longer
* acceptable. This wrapper: * acceptable. This wrapper:
* *
+1 -1
View File
@@ -24,7 +24,7 @@
* - Non-object / null / undefined input → false (nothing to migrate). * - Non-object / null / undefined input → false (nothing to migrate).
* - **v1.4.14 pre-strict shape** (`{changed, stable, drivers, ...}` — * - **v1.4.14 pre-strict shape** (`{changed, stable, drivers, ...}` —
* no `summary`, no `recommendations[]`, no `findings[]`): true. This * 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 * `safeParse` failed against `insightResultSchema`, fell through to
* the raw blob, and the rich card called `.replace()` on * the raw blob, and the rich card called `.replace()` on
* `undefined`. Detect by absence of *both* `summary` (string) and * `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). * 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 * "Es darf null Halluzinationen haben und es muss sich halt irgendwie
* stützen auf medizinische Dinge." * stützen auf medizinische Dinge."
* ("Zero hallucinations. Must ground on medical facts.") * ("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 * The chain runner wraps `generateInsight()` with a hard-failure
* walkthrough across an ordered list of providers. The motivation is * 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 * mandate: a 401 from Codex (OAuth expired), a 503 from OpenAI
* (upstream brown-out), or a network reset MUST cascade to the next * (upstream brown-out), or a network reset MUST cascade to the next
* configured provider rather than 422-ing the whole request. * 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). * 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 * muss sich halt irgendwie stützen auf medizinische Dinge." ("It must
* have zero hallucinations and must ground on medical facts.") * 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". * the rec — e.g. "+5 bpm above baseline over 7 of 7 days".
* *
* Empty `comparedTo` or `deviation` is rejected so the model cannot * 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. * zero hallucinations, every rec must trace back to user data.
*/ */
export const aiRecommendationRationaleSchema = z.object({ export const aiRecommendationRationaleSchema = z.object({
@@ -134,7 +134,7 @@ export const aiRecommendationSchema = z
text: z.string().min(1, "recommendation.text required"), text: z.string().min(1, "recommendation.text required"),
/** /**
* Mandatory citation — the data point that justified the * 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. * called out: every recommendation must trace back to user data.
*/ */
metricSource: metricSourceSchema, metricSource: metricSourceSchema,
@@ -228,7 +228,7 @@ export type AIWarning = z.infer<typeof aiWarningSchema>;
* v1.4.19 (which predate the field) still parse cleanly. Fresh * v1.4.19 (which predate the field) still parse cleanly. Fresh
* generations after the v1.4.20 PROMPT_VERSION bump emit the block. * 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, * model to derive every finding from a number visible in the snapshot,
* and `keyFindings.length` is hard-capped at 5 so a runaway model * and `keyFindings.length` is hard-capped at 5 so a runaway model
* cannot pad the surface with filler. * cannot pad the surface with filler.
@@ -21,7 +21,7 @@ describe("isBpReadingInTarget()", () => {
* v1.4.16 A2 regression: 117/79 is textbook normotensive * v1.4.16 A2 regression: 117/79 is textbook normotensive
* (well-controlled, below the goal ceiling, above hypotension). The * (well-controlled, below the goal ceiling, above hypotension). The
* v1.4.15 implementation rejected this because sys < sysLow (120), * 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. * one-sided ceiling check with a clinical floor.
*/ */
it("counts a normotensive reading below the goal band as in-target", () => { 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 * that "are clearly in target". A query against production showed
* his last 30 days of paired readings include 117/79, 122/76, 108/76, * 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. * 106/73, 127/86, 115/78, 108/75, 124/82, 126/80, 133/95.
@@ -186,9 +186,9 @@ describe("computeBpInTargetPct", () => {
* Result: 5/10 = 50 %. * Result: 5/10 = 50 %.
* *
* Compare with the v1.4.15 narrow-band result (0/10 = 0 %) — that's * 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 = [ const sys = [
reading("2026-05-08T07:38:22Z", 117), reading("2026-05-08T07:38:22Z", 117),
reading("2026-05-03T21:22:02Z", 122), reading("2026-05-03T21:22:02Z", 122),
@@ -237,12 +237,12 @@ describe("computeBpInTargetPct", () => {
describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => { 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 * **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for his
* production data. Hand-counted hypothesis-1 from the brief (the * production data. Hand-counted hypothesis-1 from the brief (the
* three-way coincidence cannot be data — it's a calculation pin): * 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 7 days: 1 / 2 in target = 50 %.
* - Last 30 days: 5 / 10 in target = 50 %. * - Last 30 days: 5 / 10 in target = 50 %.
* - All time: 62 / 572 in target ≈ 11 %. * - All time: 62 / 572 in target ≈ 11 %.
@@ -301,8 +301,8 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
expect(result.allTime).toBeNull(); expect(result.allTime).toBeNull();
}); });
it("regression: Marc's 30-day fixture mirrors Marc's prod headline bug", () => { it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => {
// Reuse Marc's production fixture (10 pairs, 5 in target = 50 %). // Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %).
// When the input is JUST those 10 pairs, allTime equals 30d == 50 % // When the input is JUST those 10 pairs, allTime equals 30d == 50 %
// (this is the legitimate coincidence — it only hides the bug when // (this is the legitimate coincidence — it only hides the bug when
// the user has no older history). The bug surfaces the moment older // 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", () => { 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 * the 30-day headline (50 %) but `7T: —` and `30T: —` placeholders
* even though the user has paired BP readings in both windows. Root * even though the user has paired BP readings in both windows. Root
* cause: the analytics route only returned `bpInTargetPct` once over * 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 * 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. * 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 * the last 30 days, 2 of them within the last 7 days) so a future
* reviewer can re-derive the expected counts by hand. * 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 * target, 5 out → 50 %), of which 2 are inside the last 7 days
* (1 in target, 1 out → 50 %). * (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 fixtureNow = new Date("2026-05-09T12:00:00Z");
const sys = [ const sys = [
reading("2026-05-08T07:38:22Z", 117), // 7d IN 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. * 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 * 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 * 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 * 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 // 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 // the 1-decimal formatter — and the test in the chart wrapper is
// about whether ANY signal survives. The split-half delta ≈ +5 kg // 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 months = 36;
const rawValues = Array.from({ length: months }, (_, i) => 80 + 0.3 * i); const rawValues = Array.from({ length: months }, (_, i) => 80 + 0.3 * i);
const trendValues = rawValues; // perfectly linear, trend === raw const trendValues = rawValues; // perfectly linear, trend === raw
+2 -2
View File
@@ -18,7 +18,7 @@
* `sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`. * `sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`.
* With ESH 2023 narrow targets (Sys 120-129, Dia 70-79 for under-65) * With ESH 2023 narrow targets (Sys 120-129, Dia 70-79 for under-65)
* this collapses to 0 % for healthy normotensive users whose * 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 * readings like 117/79 (textbook normotensive, well-controlled) that
* were marked OUT of target because sys < 120. Users intuitively * were marked OUT of target because sys < 120. Users intuitively
* consider those readings *in target* — and so does clinical * 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`, * - v1.4.18 A1 wired `avg7` + `avg30` from `computeBpInTargetWindows`,
* BUT the analytics route also routed the **headline** * BUT the analytics route also routed the **headline**
* (`bpInTargetPct`) through `windows.last30Days?.pct` — i.e. the * (`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 * data (572 paired readings since 2022, recent 30 days = 50 %, all-time
* ≈ 11 %) the tile rendered `50 %` headline, `7T: 50, 30T: 50` and * ≈ 11 %) the tile rendered `50 %` headline, `7T: 50, 30T: 50` and
* looked algorithmically pinned to 50/50/50. That was hypothesis-1 * 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"): * Backstory — v1.4.15 Fix 4 ("7-Tage-Schnitt" → "7-Tage-Trend"):
* the dashboard tiles labelled the 7-day average as "7T:" / "7d:" * 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 * (sounded like an "average") when the goal of the tile is to
* convey *direction* over the past week. We re-use the existing * convey *direction* over the past week. We re-use the existing
* 7-day linear-regression slope (already in `DataSummary.slope7`) * 7-day linear-regression slope (already in `DataSummary.slope7`)
@@ -18,7 +18,7 @@
* slope7 is unavailable. Some metrics (mood, sleep) have <2 entries * slope7 is unavailable. Some metrics (mood, sleep) have <2 entries
* within the trailing 7-day window for users who don't log daily, * 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 — * 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 * Zahlen dargestellt." A 30-day slope still answers the question
* "is this metric drifting up or down?" — we just project it onto * "is this metric drifting up or down?" — we just project it onto
* a 7-day window for label consistency. Returning `null` is reserved * 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 * for long ("All") ranges where the per-week rate
* rounds to ±0 in the 1-decimal formatter. * 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 * "+0.0 kg/Woche" on the All filter, even when his weight clearly
* shifted across years. Root cause: `weeklyDelta = (last − first) / days * shifted across years. Root cause: `weeklyDelta = (last − first) / days
* × 7` is mathematically correct but visually rounds to zero whenever * × 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 * `visible` so existing saved layouts keep their previous behaviour
* (one toggle controls both surfaces). When set explicitly the user * (one toggle controls both surfaces). When set explicitly the user
* can show the chart but hide the tile, or show the tile but hide * 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`). * (memory `feedback_dashboard_top_tiles_selectable.md`).
*/ */
tileVisible?: boolean; tileVisible?: boolean;
@@ -95,7 +95,7 @@ export function isComparisonBaseline(
/** /**
* v1.4.18 — per-chart overlay-prefs. * 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 * personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface so each chart can be tuned independently. * a per-chart switch surface so each chart can be tuned independently.
* The three toggles (7-day trend / Trend arrow / Target range) live in * 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 // v1.4.15 phase-B4 — recent-unlocks card (dashboard surface for the
// gamification feature). Visible by default — the card is small, // gamification feature). Visible by default — the card is small,
// self-gates on having any achievements (otherwise it shows a brief // 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 // `tileVisible` is forced false because there is no tile surface
// for this widget; only the chart-row card. // for this widget; only the chart-row card.
{ id: "achievements", visible: true, tileVisible: false, order: 13 }, { 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 * `Accept-Language: de, en;q=0.5` hint is sent so providers return
* native city names ("Nürnberg") rather than ASCII folds * native city names ("Nürnberg") rather than ASCII folds
* ("Nuremberg"). The previous path lost umlauts in production for at * ("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. * /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 // v1.4.17 hotfix — defensive: reading `insight.summary` on a legacy
// cached payload (no `summary` field) returns undefined. Crashing // cached payload (no `summary` field) returns undefined. Crashing
// with `Cannot read properties of undefined (reading 'replace')` was // 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. // null/undefined as the empty string instead.
it("returns empty string for undefined input", () => { it("returns empty string for undefined input", () => {
expect(stripChartTokens(undefined)).toBe(""); 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 // v1.4.19 A3 — strip vs parse split. The strip-side character class
// is permissive (`[A-Za-z0-9_]+`) so model-emitted lowercase or // is permissive (`[A-Za-z0-9_]+`) so model-emitted lowercase or
// snake_case tokens like `metric:blood_pressure_sweet_spot` (verbatim // 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 / // DOM. The parse-side class stays uppercase-only so unrenderable /
// hallucinated tokens are dropped from the *render* path entirely; // hallucinated tokens are dropped from the *render* path entirely;
// only the *strip* is permissive. // only the *strip* is permissive.
@@ -3,7 +3,7 @@
* *
* The state-machine threshold tests live in `status.test.ts` (when * The state-machine threshold tests live in `status.test.ts` (when
* does the alert fire?). This file proves WHAT the alert looks like * 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. * starts crashing for one of his users.
* *
* The formatter is a pure function so we can test deterministically * 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 * Per-minute host-load sampler that backs the admin /admin/system-status
* "last 2 hours" chart (v1.4.16 phase B3). * "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 * 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 * whether the apps-01 host is healthy. We considered scraping Coolify's
* Sentinel collector, but the Coolify HTTP API does not expose a clean * Sentinel collector, but the Coolify HTTP API does not expose a clean