Release v1.4.22

d71e879 chore(release): v1.4.22
bbdc3fb style: prettier on W5 reconcile touchpoints
b094eaa docs(planning): record Wave 5 reconcile + v1.4.22 backlog
6c0c69c fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
1631d80 refactor: simplify-pass apply-yes cleanup
65a8b5a fix(coach): pin medical disclaimer at bottom of message thread
651a529 fix(insights): collapse BP tile to single secondary row at <sm
a2e24b1 a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce)
2075d39 refactor(api): extract createSseStream helper from chat route
b1ce6eb refactor(auth): fold onboarding-cookie write into createSession + destroySession
ef597bf fix(insights): align BD-Zielbereich delta math with comparison window
75ccc01 fix(coach): graceful fallback when sentinel-only output strips empty
363e73c chore: voice + filename hygiene + wave-4 state wrap
1499e6f i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums)
15796c1 fix(auth): move onboarding redirect from AuthShell useEffect to proxy
b281c06 ops(deploy): force=true on Coolify webhook + node-26-alpine investigation
1643a78 fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt)
a22f778 feat(targets): 30-day sparkline + Δ-vs-last-month caption per card
7c46cc9 chore(planning): record Wave 3 Coach polish completion
b845cf5 refactor(coach): drawer polish — Gravatar avatar, disclaimer move, cog removal
bc87fa3 feat(coach): collapsible evidence-block disclosure under assistant bubbles
7b8efaa feat(coach): parse evidence-block sentinel out of streamed prose
bea55ea feat(coach): warm motivational rewrite with evidence-block sentinel
1c0cd8c docs(planning): record Wave 2 — Insights surface polish
59a4a89 fix(insights): strip metric tokens in recommendation prose + DE component labels
4d3b129 feat(insights): rename "Muster" + lift section tabs above the hero
36b9db3 fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data
706aa5f feat(settings): comparison-overlay becomes a global dashboard preference
b39d5f8 feat(insights): BP tile gains trend arrow + comparison overlay parity
8606767 fix(analytics): BP target headline reads last-30d, all-time becomes sub-row
e794f57 docs(planning): v1.4.22 marathon scaffold — polishing before v1.5 iOS
16b6976 Merge main into develop — sync v1.4.21 brief
3785fec fix(e2e): scroll first chart card into view before counting axis ticks
e8d0660 fix(e2e): exempt truncate-clamped elements from per-element overflow walk
812b68a fix(e2e): scroll trigger into view + force-click on portalled toggle
f181ab7 fix(e2e): pin onboardingCompletedAt so AuthShell does not redirect
32cd185 fix(e2e): adopt HeroStrip data-slot on insights advisor spec

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 22:32:20 +02:00
commit 005fed096c
144 files changed
+5380 -1407

No files matched your search

+15 -1
View File
@@ -132,10 +132,24 @@ jobs:
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable."
exit 0
fi
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
# and re-pull the registry digest. If the maintainer's secret
# URL already carries a `?force=` query, leave it alone;
# otherwise append `?force=true` (or `&force=true` if other
# query params are present) so doc-only deploys still trigger
# a registry-digest check.
deploy_url="$COOLIFY_WEBHOOK"
if ! echo "$deploy_url" | grep -q "force="; then
if echo "$deploy_url" | grep -q "?"; then
deploy_url="${deploy_url}&force=true"
else
deploy_url="${deploy_url}?force=true"
fi
fi
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
response=$(curl --silent --show-error --max-time 30 \
--write-out "\n__HTTP_STATUS__:%{http_code}" \
--request GET "$COOLIFY_WEBHOOK" \
--request GET "$deploy_url" \
--header "Authorization: Bearer $COOLIFY_TOKEN")
status="${response##*__HTTP_STATUS__:}"
body="${response%__HTTP_STATUS__:*}"
+15 -4
View File
@@ -1,12 +1,23 @@
# HealthLog v1.4.22 — roadmap
Milestone: **v1.4.22** (kicked off 2026-05-10, post-v1.4.21 patch)
Milestone: **v1.4.22 — big polishing release before v1.5 iOS** (kicked off 2026-05-10, post-v1.4.21 patch)
Latest tag at start: v1.4.21 (live in prod, image digest
`sha256:4e818d44702c…`)
| Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| G | E2E fix wave — 7 stale-selector / layout regressions surfaced post-v1.4.20 (HeroStrip slot, onboarding-card, chart-overlay, api-tokens-mobile, charts-mobile) | in flight |
Marc's directive: close ALL known bugs + apply ALL deferred items
from `.planning/v1421-backlog.md` so the iOS app builds on a clean
foundation. Marathon-style execution: research/probe first, then
parallel polish waves, then multi-agent QA, then release.
| Wave | Goal | State |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, Metrik token leaks, Targets page brainstorm); health-coach prompt research | in flight |
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | pending |
| 3 | Coach polish — B1 prompt rewrite, B2 collapsible provenance, B3 Gravatar parity, B4 disclaimer move, B5 settings cog wire-or-remove | pending |
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte upgrade, C2 api-tokens 5th attempt, C3 Coolify auto-deploy, C4 AuthShell flicker, C5 node-26-alpine, D backlog wave | pending |
| G | E2E fix wave (already on `develop`) — 7 stale-selector / layout regressions; release-merge brings them into main with v1.4.22 | done |
| 5 | Multi-agent QA + Product-Lead review | pending |
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy (validated by C3), smoke, GH release, docs+landing sync, brief | pending |
Carry-over candidates already captured in
`.planning/v1421-backlog.md` (now serving as the v1.4.22 backlog
+146 -332
View File
@@ -1,384 +1,198 @@
# v1.4.20 marathon — state log
# v1.4.22 marathon — state log
Status: in flight
Last update: 2026-05-10T15:00+02:00
Status: Wave 5 — multi-agent QA + reconcile complete
Last update: 2026-05-10T22:30+02:00
> Previous milestone: v1.4.19 live (image digest
> `sha256:b48f93874cdb…`, `/api/version=1.4.19`).
> v1.4.20 = Insights redesign with AI Coach
> (handoff at `~/Downloads/design_handoff_insights_redesign`).
> v1.5 reserved for iOS app + Apple Health integration.
> Previous patch: v1.4.21 live (image digest
> `sha256:4e818d44702c…`, `/api/version=1.4.21`).
> v1.4.22 = big polishing release before v1.5 iOS. Closes ALL known
> bugs + applies ALL deferred items from `.planning/v1421-backlog.md`
> so the iOS app builds on a clean foundation.
## Foundation — pre-flight before any feature work
## Wave 1 — Research + probe (parallel)
Per `.planning/v1420-marathon-handoff.md`, F1–F6 must complete before
B1 dispatches.
Per `feedback_research_before_complex_features.md`, deep-fix work on
4-attempt bugs (BD-Zielbereich, api-tokens scrollbar) + Coach prompt
rewrite needs research first.
### F0 — Commit dangling v1.4.19 reports + bootstrap v1.4.20 scaffold
### W1a — Playwright PROD probe + token-leak hunt
- [x] Commit phase-E1-report.md + phase-E3-report.md v1.4.19 updates
- [x] STATE.md replaced with v1.4.20 scaffold; v1.4.19 archived to ROADMAP.md
- [x] ROADMAP.md updated with v1.4.20 milestone + previous-milestone archive
- [ ] BD-Zielbereich tile rendering against PROD with maintainer
session (4-attempt bug pattern: probe before patching)
- [ ] Admin / api-tokens scrollbar at multiple viewports (5th attempt)
- [ ] Raw `Metrik <Type>` token leaks in Recommendations / Findings
- [ ] Targets / Zielwerte page screenshot for upgrade brainstorm
- [ ] Inventory `.planning/v1421-backlog.md` items by complexity
- Detailed report: `.planning/phase-W1a-report.md`
### F1 — Branch model: long-lived `develop`
### W1b — Health-coach prompt research
- [x] Create `develop` from cleaned `main` HEAD (`a1cf9bc`)
- [x] Push `develop` to origin (tracking set up)
- [x] GHCR workflow verified — only `branches: [main]` + `tags: ['v*']` push paths build/publish; PRs to `main` build but do not push; develop is build-free
- [x] All v1.4.20 commits target `develop`; release-merge to `main` only at Phase E
- Detailed report: `.planning/phase-F1-report.md`
- [ ] Benchmark health-coach / motivational-interviewing prompt
patterns (Apple Health Coaching, Oura, Whoop, …)
- [ ] Define "natural prose first, evidence collapsible" structure
with concrete EN + DE drafts
- [ ] PROMPT_VERSION 4.20.2 → 4.22.0 (significant Coach tone shift)
- Detailed report: `.planning/phase-W1b-coach-prompt-research.md`
### F2 — Document branch + release model
## Wave 2 — Insights surface polish
- [x] Extend `CONTRIBUTING.md` with branch flow + ASCII diagram (this branch)
- [x] Mirror page on healthlog-docs at `/contributing/branch-model/` (`5d96861` on healthlog-docs/main)
- [x] User-vs-contributor distinction explicit in both surfaces
- Detailed report: `.planning/phase-F2-report.md`
### A1 — BD-Zielbereich 5th attempt
### FX — User-facing artifact cleanup (PII + jargon + German leaks)
- [x] Headline re-anchored to `windows.last30Days?.pct`; all-time
surfaced as `bpInTargetPctAllTime` for the new sub-row
- [x] Integration test pins the route contract
- Detailed report: `.planning/phase-W2-report.md`
Combined sweep across HealthLog main repo + healthlog-docs + healthlog-landing
(F3 + F4 + new PII rule consolidated into one wave).
### A2 — BD-Kachel feature parity
- [x] HealthLog main repo: 20 files scrubbed, `a1cf9bc` on origin/main
(CHANGELOG.md, docs/audit/v146 / v1414 / v1415 / v1416 / v1418 /
v1419, docs/codex-protocol-spec.md, docs/migration/, docs/ops/)
- [x] healthlog-docs: 7 files scrubbed, `782862b` on origin/main
(api/ examples replaced "marc" → "alice", DE leaks fixed)
- [x] healthlog-landing: 1 file scrubbed, `9b31083` on origin/main
(JSON-LD author block depersonalised)
- [x] GH releases v1.4.14–v1.4.19: republished bodies from cleaned
CHANGELOG sections during Phase E pipeline
- [ ] Source-comment sweep (`src/` has 191 `Marc` references in
maintainer comments) — defer to v1.4.21 backlog as low-priority
- [ ] DE+EN bilingual CHANGELOG entries (v1.4.15 era) — defer; larger
rewrite, captured in v1.4.21 backlog
- Detailed report: `.planning/phase-FX-report.md`
- [x] Trend arrow + 7-day-trend chip + comparison overlay parity
(synthesised slope from 7d/30d delta); `<TrendCard>` extended
with optional `avgAllTime` sub-value
### F5 — Best-practice GitHub repo audit
### A3 — Comparison-Toggle → global Settings
- [x] CODE_OF_CONDUCT.md (Contributor Covenant 2.1, `e7b6b27`)
- [x] Issue + PR templates (`.github/ISSUE_TEMPLATE/*.yml`, `PULL_REQUEST_TEMPLATE.md`, `072941b`)
- [x] Dependabot expanded to github-actions + docker (`779e5a3`)
- [x] package.json metadata: description, license, homepage, repository, bugs, keywords (`f7977d1`)
- [x] LOW carry-over (README badges, FUNDING.yml, repo-root tidy) deferred to `.planning/v1421-backlog.md`
- Detailed report: `.planning/phase-F5-report.md`
- Note: initial sub-agent dispatch hit a content-filter block on the CoC text; refactored to inline execution (download CoC verbatim from contributor-covenant.org).
- [x] On-surface `<CompareToggle />` removed from `/insights`;
Settings → Dashboard already carries the canonical picker
- [x] `User.dashboardWidgetsJson.comparisonBaseline` field already
exists (v1.4.16 phase B8) — every chart still consumes it
### F6 — Multi-agent QA on new docs
### A4 — Insights layout grid normalisation
- [x] 38 docs site pages cross-checked against the live app (v1.4.19), CHANGELOG, `.env.example`, Dockerfile, docker-compose, docker-publish workflow
- [x] CRITICAL fixed inline: `19eb8de` (S3 env-var names + arm64 build claim removed) on healthlog-docs/main
- [x] HIGH fixed inline: `3d3ea21` (achievement count 38 → 59, `/admin/<section>` paths, AI rate-limit 2/h → 10/h, default-locale flip, comparison-overlay scope) on healthlog-docs/main
- [x] 9 MED + 5 LOW deferred to `.planning/v1421-backlog.md` "Docs site audit" section
- [x] Two flagged-but-uncertain items captured for maintainer review
- Detailed report: `.planning/phase-F6-report.md`
- [x] Row-fill rule: BP / weight / medications grids collapse to
full-width when only one card has data
- [x] Trends row equal heights via `min-h-[300px]`
- [x] `<CorrelationRow>` drops insufficient-data tiles; zero ok
cards hides the row entirely
## Wave B — Insights redesign with AI Coach (sequential)
### A5 — Section heading + tab rename
Per `.planning/phase-D-v1419-product-lead-review.md`. Each phase
ships behind a feature flag and unlocks independently.
- [x] "Muster" → "Zusammenhänge" (DE) / "Patterns" → "Relationships"
(EN). Picked Zusammenhänge over Trends because the row above
already uses Trends.
- [x] `<InsightsSectionNav>` lifted above the hero strip (sticky
scroll-anchored; tabs visible first)
### B1 — Hero strip + Daily Briefing + Suggested-prompts (~3-5 days)
### A6 — Raw `Metrik <Type>` token leak fix
- [x] Replace `<InsightsPageHero>` with new hero strip (greeting,
action row + suggested-prompt strip; Daily Briefing card mounts
full-width below the hero)
- [x] New `src/components/insights/{hero-strip,daily-briefing,
suggested-prompts}.tsx`
- [x] Extend `aiInsightResponseSchema` with `dailyBriefing`
(`paragraph`, `keyFindings[]`)
- [x] PROMPT_VERSION bump 4.19.0 → 4.20.0
- [~] Apple Health gated tiles — descoped per `phase-D-v1419-product-
lead-review.md` decision. The 4-vitals tile row from the artboard
uses BP / Weight / Pulse / Mood (all data already in the app);
HRV / Sleep / Resting-HR tiles defer to v1.5 / iOS app. The B1
tile row itself (Vitals at-a-glance) is deferred to a later
B-phase since it duplicates the per-section status cards below
— see B1 report for the rationale.
- Detailed report: `.planning/phase-B1-v1420-report.md` (the older
`phase-B1-report.md` is from the v1.4.18 achievements marathon)
- [x] `<RecommendationCard>` text wrapped in `stripChartTokens()`
- [x] DE `componentMood`/`componentBp`/`componentCompliance` renamed
to German nouns; i18n-integrity test pins the contract
### B2 — AI Coach panel + streaming chat + persistence (~5-7 days)
## Wave 3 — Coach polish
Split into B2a (backend, complete) + B2b (drawer UI, complete). Both
halves landed on `develop` under nine atomic commits total — five
for the backend wave, four for the drawer wave plus a state-tick
commit.
### B1 — Prompt rewrite
- [x] New `POST /api/insights/chat` SSE-streaming endpoint
- [x] New `GET /api/insights/chat` (list) + `GET/DELETE
/api/insights/chat/[id]` (single + delete)
- [x] New Prisma `CoachConversation` + `CoachMessage` + `CoachUsage`
(encrypted content, GDPR cascade)
- [x] Source-chip provenance attached to every assistant message
- [x] Token-budget cap (25 000 tokens / user / UTC-day) + 20-turn
conversation cap with summarised-history fold
- [x] Prompt-injection + off-topic refusal pattern (EN+DE)
- [x] Drawer overlay over `/insights`; full-page `/insights/coach`
route deferred to v1.5 paired with the iOS app
- [x] New `src/components/insights/coach-panel/*` — drawer shell,
message thread, source chips, composer, history rail,
sources rail, TanStack Query + SSE hooks
- Detailed report: `.planning/phase-B2a-report.md` (backend);
`.planning/phase-B2b-report.md` (drawer UI)
- [x] Adopt W1b research output: natural prose-first, motivational,
conservative, evidence-grounded
- [x] PROMPT_VERSION 4.22.0; EN + DE
- [x] Coach-specific test suite covering tone + evidence-collapsible
pattern
- Detailed report: `.planning/phase-W3-report.md`
### B3 — Correlation discovery + Trends row with AI annotations (~3-5 days)
### B2 — Collapsible provenance + values
- [x] 3 pre-defined hypotheses: BP×compliance, mood×pulse,
weight×weekday
- [x] New `src/lib/insights/correlations.ts` (Pearson + weekday-ANOVA;
n >= 14 + p < 0.05 surfacing gate)
- [x] New `src/components/insights/{correlation-card,correlation-row,
trends-row,trend-annotation}.tsx`
- [x] Confidence interval via Fisher z-transform; conservative
phrasing locked in by code-review convention
- [x] PROMPT_VERSION 4.20.0 → 4.20.1; new `trendAnnotations` block
on the AI response schema (nullable + optional for legacy
payloads)
- [x] `/api/analytics` now emits `correlations: {bpCompliance,
moodPulse, weightWeekday}` server-side
- [x] `<CorrelationRow>` + `<TrendsRow>` mounted on `/insights`
between Daily Briefing and the Advisor card
- Detailed report: `.planning/phase-B3-report.md`
- [x] Move inline raw values out of prose into a
"Worauf bezieht sich das?" expandable below each assistant
message; `---KEYVALUES---` … `---END---` sentinel stripped
server-side, parsed into `provenance.keyValues`
- [x] Source chips stay visible; values reveal on click
### B4 — Weekly Report + Storyboard + Mobile passes (~3-4 days)
### B3 — User avatar = Gravatar (parity)
- [x] PROMPT_VERSION 4.20.1 → 4.20.2; aiInsightResponseSchema gains
`weeklyReport` + `storyboardAnnotations`; GROUND RULES 10/11
across EN + DE prompts
- [x] New `/insights/report/[week]` route — newsletter-style
printable surface with Summary / Going-well / Worth-watching /
Tips / Data-quality sections; `window.print()` export via
Tailwind `print:` variants
- [x] Hero strip banner-card surfaces the fresh weeklyReport with
Read · Share · Export PDF actions; Share uses Web Share API +
clipboard fallback; Export deep-links to `?print=1`
- [x] `<HealthChart>` gains an additive `annotations[]` prop;
`resolveAnnotationPositions()` pure helper maps date→pointIndex
with a 7-day snap window and 24-char small-viewport truncation
- [x] Coach drawer rail trays — chevron-button triggers on `<lg`
open side-sheets that host the same HistoryRail / SourcesRail
the desktop layout uses; refactored body into
`<CoachDrawerBody>` for SSR-test surface
- Detailed report: `.planning/phase-B4-report.md`
- [x] Same dimensions as Coach avatar
- [x] Reuses existing `gravatarUrl` from `/api/auth/me`; initials
fallback when no Gravatar configured
### B5 — Personal AI Coach Health Score (~2-3 days)
### B4 — Disclaimer move
- [x] Composite Health Score (0-100): 30% BP-target + 20%
weight-trend + 20% mood-stability + 30% compliance
- [x] Three bands (≥75 green, 50–74 yellow, <50 red)
- [x] "Ask the Coach" CTA opens B2 drawer with prefilled prompt
- Detailed report: `.planning/phase-B5-report.md`
- [x] "Coach replies are generated. Clinical decisions belong with
your doctor." moves from below input → sources rail footer
- [x] "Coach reads only your connected data" caption replaced by the
disclaimer so the source picker stands alone
## Wave B summary (B1–B5)
### B5 — Settings cog wire-or-remove
Wave B closed the Insights redesign across 5 phases on `develop`:
- [x] Removed for v1.4.22; per-user prompt-tuning surface deferred
to v1.4.23 (no dead buttons in this release)
| Phase | Theme | Commits | Test count delta (unit / int) |
| ----- | ------------------------------------------ | ------- | ----------------------------- |
| B1 | Hero strip + Daily Briefing + Suggested | 5 | 1672 → 1753 (+81) / 67 → 67 |
| B2 | AI Coach drawer (B2a backend + B2b UI) | 5 + 4 | 1753 → 1833 (+80) / 67 → 78 |
| B3 | Correlation discovery + Trends row + AI | 5 | 1833 → 1907 (+74) / 78 → 78 |
| B4 | Weekly Report + Storyboard + Mobile passes | 5 | 1907 → 1975 (+68) / 78 → 78 |
| B5 | Personal Health Score | 5 | 1975 → 2026 (+51) / 78 → 81 |
| | **Cumulative** | **29** | **+354 / +14** |
## Wave 4 — Other surfaces + backlog cleanup
Test count: 1672 → 2026 unit (+354), 67 → 81 integration (+14).
Test files 217 → 237. typecheck clean every phase; lint
13 baseline warnings unchanged.
### C1 — Zielwerte page upgrade
## Wave D — Multi-agent QA + Product-Lead review
- [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.
- [x] code-reviewer (`.planning/phase-D-v1420-code-review.md`)
- [x] security review (`.planning/phase-D-v1420-security-review.md`)
- [x] design / UX review (`.planning/phase-D-v1420-design-review.md`)
- [x] senior-dev review (`.planning/phase-D-v1420-senior-dev-review.md`)
- [x] simplify (`.planning/phase-D-v1420-simplify-review.md`)
- [x] Product Lead — v1.5 redesign plan (`.planning/phase-D-v1420-product-lead-review.md`)
- [x] Reconcile applied CRITICAL + HIGH (13 of 13) + 6 MED inline; 22 MED + 16 LOW + 4 simplify-apply-maybe deferred to `.planning/v1421-backlog.md` "Phase D reconcile carry-over" section
- Detailed report: `.planning/phase-D-v1420-reconcile-report.md`
### C2 — Admin / api-tokens scrollbar 5th attempt
## Phase E — Release v1.4.20
- [x] Live Playwright probe confirmed `whitespace-nowrap` on the date
`<td>`s was the residual culprit (W1a §2). Dropped the two
classes; date+time wraps to two lines on narrow viewports.
- [x] Pre-release verify (typecheck, lint, test, integration,
format, build) — 2026 unit + 81 integration green
- [x] Bump package.json + CHANGELOG (`aac8b5e chore(release): v1.4.20`)
- [x] Sync Dependabot dep bumps from main → develop (`dd2ef57`)
- [x] Release-merge develop → main (`666f6ee Release v1.4.20`)
- [x] Tag + push v1.4.20 (annotated, `666f6ee`)
- [x] GHCR build green (run `25633807964`, 4m02s,
digest `sha256:b112a31947…`)
- [x] Coolify deploy — auto-deploy webhook still missing on
apps-01; host-side retag fallback used (same fault mode as
v1.4.19)
- [x] /api/version=1.4.20 confirmed (flipped 2026-05-10T16:49:25Z)
- [x] Production smoke — all gated routes redirect 307 →
`/auth/login` as expected for an unauthenticated request;
`/api/version` and `/auth/login` 200
- [x] GH release published (https://github.com/MBombeck/HealthLog/releases/tag/v1.4.20)
- [x] GH releases v1.4.14 → v1.4.19 republished from cleaned
CHANGELOG sections (FX carry-over)
- [x] Docs site sync (`0b83430` on healthlog-docs/main —
version pins + ai-insights v1.4.20 callout)
- [x] Landing site sync (`43ce4c7` on healthlog-landing/main —
JSON-LD `softwareVersion` 1.4.20 + 3 new featureList entries)
- [x] `docs/audit/v1420-summary.md` (release brief, `7f9ae21`)
- [x] v1.5 plan refreshed — see `.planning/phase-D-v1420-product-
lead-review.md`
### C3 — Coolify image-digest auto-deploy
---
- [x] Workflow appends `?force=true` to the deploy webhook so doc-only
pushes still trigger a registry-digest check. Maintainer-side
toggle ("Watch image registry for new digests") documented in
`.planning/coolify-auto-deploy-howto.md` — one UI flip.
## Status block — F0 (v1.4.20)
### C4 — AuthShell post-hydration redirect flicker
- 2026-05-10T15:00+02:00 — F0 complete. Dangling v1.4.19 release
reports (`phase-E1-report.md` + `phase-E3-report.md`) committed,
STATE.md replaced with v1.4.20 scaffold (F1–F6 foundation +
B1–B5 Insights redesign + Wave D + Phase E), ROADMAP.md updated
to track v1.4.20 with v1.4.19 archived. Branch model unchanged
for this commit (still `main`); F1 will introduce `develop`.
- [x] Redirect lives in `proxy.ts`. Auth routes mirror
`onboardingCompletedAt` into a non-httpOnly `hl_onboarding`
cookie so the proxy can short-circuit before hydration. The
e2e onboarding-flicker spec dropped its workaround.
## Status block — B2a (v1.4.20)
### C5 — node-26-alpine bump
- 2026-05-10T16:15+02:00 — B2a complete. AI Coach backend landed in
five atomic commits on `develop`: Prisma migration
`0035_coach_conversations_v1420` adds `CoachConversation`,
`CoachMessage`, and `CoachUsage` tables (cascade-on-user-delete);
Coach helpers (types, persistence with AES-256-GCM message bodies,
per-day 25 000-token budget ledger, EN+DE prompt-injection +
off-topic refusal); SSE streaming endpoint at POST
`/api/insights/chat` (idempotent on conversation creation only,
20-turn history fold, refusal short-circuit, provenance frame, done
frame); list / fetch-single / delete endpoints with 404-not-403
ownership boundary. Tests: 1753 → 1781 unit (+28),
67 → 78 integration (+11). UI drawer (B2b) deferred to a separate
dispatch.
- [x] Dependabot PR #162 closed with reasoning. node:26-alpine ships
without corepack pre-installed; Next.js 16 also still pins
node 22 LTS as the supported minimum. Reopen when the framework
bumps its node-version requirement.
## Status block — B2b (v1.4.20)
### D — `.planning/v1421-backlog.md` cleanup wave
- 2026-05-10T16:30+02:00 — B2b complete. AI Coach drawer landed in
four atomic commits on `develop`: TanStack Query + SSE streaming
hooks (`useCoachConversations`, `useCoachConversation`,
`useDeleteCoachConversation`, `useSendCoachMessage`) with a
pure `parseSseChunk()` helper for unit-testable frame parsing;
drawer shell wrapped in `<Sheet side="right">` with three-column
layout on `lg+` (history rail · message thread · sources rail) and
full-screen single-column on mobile; `<MessageThread>` renders user
/ assistant bubbles + an in-flight streaming bubble with
pinned-to-bottom auto-scroll; `<SourceChips>` renders provenance as
labels-only chips (metric · window · n-count, never raw values);
`<CoachInput>` composer with Enter / Cmd+Enter / Shift+Enter
semantics, disabled mic placeholder ("voice arrives in v1.5"), and
the "Coach replies are generated. Clinical decisions belong with
your doctor." disclaimer; `<HistoryRail>` with substring filter
and confirm-then-go delete; `<SourcesRail>` with the five Coach
metric contracts; `/insights` page mounts the drawer with the
hero strip's "Ask the coach" button + suggested-prompt chips
driving open + prefill. Tests: 1781 → 1833 unit (+52,
six new test files: `use-coach`, `source-chips`, `message-thread`,
`coach-input`, `sources-rail`, `history-rail`). Integration count
unchanged at 78. typecheck + lint clean (12 baseline warnings).
Apply or confirm-defer every item:
## Status block — Wave D reconcile (v1.4.20)
- [x] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe)
- [x] FX carry-overs:
- [x] 191 `Marc` references swept across `src/` (test fixtures kept
as opaque test data per brief)
- [x] DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15) reduced
to English-only
- [x] `CLAUDE.md` renamed to `CONTRIBUTING-AI.md` so the filename
is not AI-vendor-specific; `AGENTS.md` stays for multi-agent
compatibility. `CONTRIBUTING.md` reference updated.
- 2026-05-10T18:15+02:00 — Wave D reconcile complete. Six reviewers
filed 0 CRIT · 12 HIGH · 28 MED · 22 LOW + 5 simplify-apply-yes +
4 simplify-apply-maybe + 3 simplify-apply-no across the code,
security, design, senior-dev, simplify, and product-lead lenses.
All 12 HIGH items + the design pushback on the disabled weekly-
report button + 6 MED items + every simplify-apply-yes item landed
inline across 11 atomic commits on `develop` (`e632e26` →
`31fbf98`). 22 MED + 16 LOW + 4 simplify-apply-maybe deferred to
`.planning/v1421-backlog.md` under "Phase D — v1.4.20 reconcile
carry-over". Test counts unchanged (2026 unit / 81 integration);
typecheck + lint clean; one observed integration-test flake on
`coach-chat.test.ts` "round-trips" (not introduced by Phase-D —
consistent re-runs pass). Phase E (release v1.4.20) unblocked.
### E2E fix wave (already on develop)
## Phase F — v1.4.20.1 hotfix
- [x] 5 commits on develop from the v1.4.21 follow-up; release-merge
brings them into main when v1.4.22 ships
Triggered by Marc's testing of the new Insights/Coach surface in
production after v1.4.20 went live. Five issues captured in
`feedback_v1420_post_deploy_bugs.md` — landed across six atomic
commits on `develop`. No tag yet; maintainer decides whether to ship
as v1.4.20.1 or bundle into v1.4.21.
## Wave 5 — Multi-agent QA + Product-Lead review
- [x] Daily Briefing regenerate produces a non-empty briefing
(`/api/insights/generate` now uses `getStrictInsightsSystemPrompt`
so GROUND RULE 8 reaches the model — `7921ffc`)
- [x] Coach snapshot ships day-level readings with weekday labels
and a per-day medication-adherence row (`ed61b17`)
- [x] Coach system prompt instructs the model to use the timeline
block for day-specific or weekday-specific questions
(`2143377`)
- [x] Streaming bubble clears once the persisted twin lands —
message-thread render-time dedup keyed on `messageId`
(`f07e35b`)
- [x] Drawer header settings cog moved to the left side so Radix
Sheet's close-X no longer overlaps it (`ddb2914`)
- [x] Sources rail grows per-source checkboxes + window selector;
scope flows through `useSendCoachMessage` to the chat request
body and into `buildCoachSnapshot` (`08fd411`)
- [x] Detailed report: `.planning/phase-v1421-hotfix-report.md`
- [x] code-reviewer (`.planning/phase-W5-v1422-code-review.md` — 0 CRIT, 2 HIGH, 4 MED, 5 LOW)
- [x] security review (`.planning/phase-W5-v1422-security-review.md` — 0 CRIT, 0 HIGH, 2 MED, 4 LOW)
- [x] design / UX review (`.planning/phase-W5-v1422-design-review.md` — 0 CRIT, 3 HIGH, 7 MED, 5 LOW)
- [x] senior-dev review (`.planning/phase-W5-v1422-senior-dev-review.md` — 0 CRIT, 2 HIGH, 5 MED, 4 LOW)
- [x] simplify (`.planning/phase-W5-v1422-simplify-review.md` — 5 apply-yes, 4 apply-maybe, 6 apply-no)
- [x] Product Lead — v1.5 plan refresh based on v1.4.22 state (`.planning/phase-W5-v1422-product-lead-review.md`)
- [x] Reconcile applied CRITICAL + HIGH; defer MED/LOW with reasoning (10 commits; report at `.planning/phase-W5-v1422-reconcile-report.md`; backlog at `.planning/v1422-backlog.md`)
## Status block — F (v1.4.20.1 hotfix)
## Wave 6 — Release v1.4.22
- 2026-05-10 (post-deploy) — Hotfix complete. Six commits on
`develop` (`7921ffc` → `08fd411`). Test counts: 2026 → 2036 unit
(+10 net across snapshot + message-thread + sources-rail tests).
Integration tests unchanged at 81 — coach-chat.test.ts still 5/5.
typecheck + lint clean (13 baseline warnings unchanged). Token-
budget impact of the day-level snapshot: ~190 → ~3000 tokens per
full-scope turn; the new scope picker is the relief valve, single-
source narrowed turns land around 600-700 tokens. v1.4.21 should
watch usage and consider trimming `DAILY_TIMELINE_DAYS` from 14 to
10 if heavy users start hitting the 25k/day cap.
## Status block — v1.4.21 release
- 2026-05-10T17:46+00:00 — v1.4.21 LIVE in production. Tag `v1.4.21`
on `8e198e1 Release v1.4.21` (release-merge of develop into main),
GHCR build run `25635294152` succeeded, image digest
`sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c`.
Coolify auto-deploy webhook still missing (same fault as v1.4.19 +
v1.4.20) — host-side retag fallback used (`docker pull :1.4.21 →
docker tag :latest → docker compose up -d --force-recreate app`)
on apps-01. `/api/version` flipped to `1.4.21` between
19:45+02:00 and 19:46+02:00. Production smoke (no session): every
gated route 307 → `/auth/login`; `/api/version` 200; `/auth/login`
reachable. GH release: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.21.
Marc-Brief at `docs/audit/v1421-summary.md`. Docs site + landing
site sync skipped (patch is invisible at the docs page level —
the v1.4.20 entries already cover the Insights redesign +
Coach surface).
## Phase G — e2e fix wave (in flight on develop)
The post-v1.4.20 e2e workflow on `main` reported 7 failing specs.
Five are stale selectors from the B1 hero rewrite (the new
`<HeroStrip>` exposes a different `data-slot` value than the
legacy `<InsightsPageHero>` the specs assert), the other two are
Pixel-5 layout drift on `admin-api-tokens-mobile` and
`charts-mobile`. A focused fix wave is in flight on `develop`;
the next release (v1.4.22) will land them.
## Status block — B5 (v1.4.20)
- 2026-05-10T17:45+02:00 — B5 complete. Personal Health Score
landed in five atomic commits on `develop`: pure formula library
(`src/lib/analytics/health-score.ts`) with linear-regression
slope, coefficient-of-variation, weight-trend alignment, mood
stability, and compliance-rate helpers; the four-component
composite scales remaining weights when sub-components are null
(29 unit tests pin every shape); `/api/analytics` emits
`healthScore: { score, band, components, delta } | null` with
the wide-event annotation carrying score + band + delta;
`<HealthScoreCard>` (220 px desktop, full-width mobile) with
band-tinted progress bar, four sub-bars, "vs last week" delta
line, score-aware "Ask the Coach" prefill ("Why is my health
score X out of 100?"); `<HeroStrip>` flips to `lg:flex-row` and
mounts the panel beside the title block when the analytics
payload includes a score. Tests: 1975 → 2026 unit (+51), 78 → 81
integration (+3). typecheck + lint clean. Canonical sample (the
integration's strong-positive seed): score 95, band green,
components 90/100/89/100. Wave-B closed; Wave D dispatch next.
- [ ] Pre-release verify (typecheck, lint, test, integration,
format, build)
- [ ] Bump `package.json` 1.4.21 → 1.4.22 + CHANGELOG
- [ ] Release-merge develop → main
- [ ] Tag + push v1.4.22
- [ ] GHCR build green
- [ ] Coolify deploy (should work without host-side fallback after C3)
- [ ] /api/version=1.4.22 confirmed
- [ ] Production smoke + e2e workflow on main passes (e2e fix wave
from develop is included in the release-merge)
- [ ] GH release
- [ ] Docs site + landing site sync
- [ ] `docs/audit/v1422-summary.md` (release brief)
- [ ] v1.5 plan refreshed for the iOS push
+65
View File
@@ -0,0 +1,65 @@
# Coolify image-digest auto-deploy — minimal maintainer action
Status: maintainer-task — the toggle lives in Coolify's UI; neither the
Coolify MCP nor the GitHub workflow can flip it.
## The problem
Releases v1.4.19, v1.4.20, and v1.4.21 all required a host-side `docker
tag <sha> :latest` + `docker compose up -d --no-deps` fallback after
GHCR pushed the new image. Coolify pulled `:latest` from its local
cache without re-checking the GHCR registry digest, so the freshly
published image never reached production until the maintainer SSH'd
into the host and forced the retag.
Longer history: `docs/audit/v1416-auto-deploy-fix.md` documents the
v1.4.15 partial fix (workflow-side webhook trigger) and why it
half-solved the problem.
## The minimal change
In the Coolify UI for the HealthLog application:
1. Open **Configuration → Source** (or equivalent tab depending on the
Coolify version — the option may also live under "General" or
"Deploy").
2. Enable the **"Watch image registry for new digests"** /
**"Image-digest auto-deploy"** checkbox. Coolify v4 names it
slightly differently across point-releases; the one that triggers a
pull when the registry digest changes (not when a git push lands)
is the one we want.
3. Save. The next time GHCR pushes a new `:latest` digest, Coolify
will pull and recreate the container without a host-side retag.
## Why not solve it in the workflow
Two non-options were ruled out:
- **Push the explicit version tag in `docker-compose.yml`** — would
require a workflow edit on every release to bump `image:` to the new
tag. Worse than the current state (a manual host-side step) because
the release author has to remember the second commit.
- **Pre-deployment `docker pull` hook in the Coolify app config** —
duplicates what the registry-digest auto-deploy already does, but
worse: the pull runs on every deploy trigger (including doc-only
pushes) instead of only when the digest changed.
The workflow file (`.github/workflows/docker-publish.yml`) already
pings Coolify's webhook with `?force=true` after the GHCR push, so the
trigger half of the contract is in place — the missing half is the
registry-digest check, which is a Coolify-side feature.
## Verification
Push a new release tag. The expected sequence is:
1. `docker-publish.yml` builds, signs, pushes to GHCR.
2. Coolify webhook fires; Coolify pulls the new digest because
"Watch image registry" is enabled.
3. `https://healthlog.bombeck.io/api/version` returns the new version
string within ~60s of the GHCR push — without SSH.
If step 3 still requires a host-side retag, the checkbox is in the
wrong place in the UI for that Coolify version — open the audit doc
above and the v1.5 deferral note for the alternative
(GHCR-tag-promoted-by-workflow path).
+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`
+153
View File
@@ -0,0 +1,153 @@
# Wave 2 — Insights surface polish (v1.4.22)
Started: 2026-05-10T20:40+02:00
Completed: 2026-05-10T21:00+02:00
Branch: develop (post-v1.4.21)
Six atomic commits delivered to `origin/develop`.
## A1 — BD-Zielbereich headline re-anchor
`fix(analytics): BP target headline reads last-30d, all-time becomes sub-row`
Up to v1.4.19 the headline pinned to `windows.last30Days?.pct` — a
literal copy of the 30T sub-value, producing the 50/50/50 algorithmic
pin Marc reported. v1.4.19 A1 routed the headline to `allTime`, which
fixed the pin but introduced a new emotional problem: the headline
became the slowest-moving aggregate possible, punishing recent
improvement.
v1.4.22 A1 re-anchors the route's `bpInTargetPct` to
`windows.last30Days?.pct` and adds a new `bpInTargetPctAllTime` field
so the long-arc number stays accessible as a sub-value. The
`computeBpInTargetWindows()` helper is unchanged — only the route's
headline pick changed.
**Files touched**: `src/app/api/analytics/route.ts`,
`tests/integration/bp-in-target.test.ts` (two new integration cases
pinning the headline source + all-three-windows envelope shape).
## A2 — BD-Kachel feature parity
`feat(insights): BP tile gains trend arrow + comparison overlay parity`
Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
without a trend arrow, 7-day-trend chip, or comparison overlay caption.
Synthesised a slope from the difference between the 7-day and 30-day
in-target shares: 7d > 30d means recent improvement (up-good ⇒ green
arrow). The 7-day-trend chip uses the same delta.
Extended `<TrendCard>` with optional `avgAllTime` / `avgAllTimeLabelKey`
/ `avgAllTimeColorClass` props so the all-time aggregate stays visible
next to `7d:` and `30d:` after the A1 re-anchor. Other tiles leave the
field undefined so the third sub-row never renders for them.
**Files touched**: `src/components/charts/trend-card.tsx`,
`src/components/charts/__tests__/trend-card-all-time.test.tsx` (new),
`src/app/page.tsx`, `messages/{en,de}.json` (new
`charts.avgAllTimeShort` key).
## A3 — Comparison-Toggle → global Settings
`feat(settings): comparison-overlay becomes a global dashboard preference`
Per `feedback_settings_no_split.md`, removed the on-surface
`<CompareToggle />` from `/insights`. The canonical picker already
lives in Settings → Dashboard (since v1.4.16 phase B8), and the
`User.dashboardWidgetsJson.comparisonBaseline` field already persists
the value. Every chart on `/insights` still reads the resolved
baseline so flipping the Settings toggle propagates atomically.
**Files touched**: `src/app/insights/page.tsx` (removed the meta-slot
`<CompareToggle />` from `<DailyBriefing>` and the matching import),
`src/app/__tests__/insights-polish.test.ts` (new A3 describe-block
asserting the toggle is gone but `compareBaseline` plumbing remains).
## A4 — Insights layout normalisation
`fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data`
Three related layout polish wins:
- **Row-fill rule**: BP-medication grid, weight-correlation grid, and
medications-per-day grid all hard-coded `xl:grid-cols-2` /
`sm:grid-cols-2`, leaving half-rows of empty space when one of the
two slots wasn't populated. All three now switch to a single
full-width column when fewer than 2 cards render.
- **Trends row equal heights**: each `<TrendsRow>` card is a flex
column with `min-h-[300px]` so a multi-line AI annotation under one
chart doesn't push that card taller than its neighbours.
- **Hide insufficient correlations**: `<CorrelationRow>` drops cards
whose `status !== "ok"` instead of painting a placeholder. Zero ok
cards hides the row entirely (no header, no disclaimer); 1 card
spans 100 % width; 2-3 cards split 50/50 on `>=md`.
**Files touched**: `src/components/insights/correlation-row.tsx`,
`src/components/insights/trends-row.tsx`,
`src/components/insights/__tests__/correlation-row.test.tsx`,
`src/app/insights/page.tsx`.
## A5 — "Muster" rename + section tabs lift
`feat(insights): rename "Muster" + lift section tabs above the hero`
- **DE rename decision**: "Muster" → "Zusammenhänge". The W1b research
suggested "Trends" or "Zusammenhänge"; picked the latter because the
row directly above the correlation row already uses "Trends" for the
trend-annotation strip. "Zusammenhänge" carries the relational
meaning a correlation surface needs without borrowing terminology
from its neighbour.
- **EN parallel**: "Patterns" → "Relationships" for the same reason.
- **Tab lift**: `<InsightsSectionNav>` moved from below the advisor
card to the top of the page. Sticky scroll-anchored behaviour
preserved (incremental — a full SPA-tab rewrite is out of scope for
v1.4.22 polish). The user lands on Allgemein by default and sees
the metric-tab strip before scrolling.
**Files touched**: `messages/{en,de}.json`, `src/app/insights/page.tsx`,
`src/components/insights/__tests__/correlation-row.test.tsx`,
`src/components/insights/__tests__/correlation-card.test.tsx`.
## A6 — Token-leak fix + DE component labels
`fix(insights): strip metric tokens in recommendation prose + DE component labels`
W1a §3 traced the production "metric:WEIGHT" leak to
`src/components/insights/recommendation-card.tsx:336`: every other
AI-prose surface ran through `stripChartTokens()`, the recommendation
text didn't. Two-line fix.
The bonus DE-locale defect: `componentMood: "Mood"` (English in the
German file) plus the related `componentBp: "BD"` /
`componentCompliance: "Medis"` abbreviations. Marc reported "Mut" —
voice-to-text rendering of "Mood". All four labels now read natively
German: `"Blutdruck"`, `"Gewicht"`, `"Stimmung"`, `"Einnahmetreue"`.
i18n integrity test added to pin the four DE component labels so a
copy-paste regression can't reintroduce the leak.
**Files touched**: `src/components/insights/recommendation-card.tsx`,
`src/components/insights/__tests__/recommendation-card.test.tsx`,
`messages/de.json`,
`src/lib/__tests__/i18n-locale-integrity.test.ts`.
## Test delta
| Layer | Before | After |
| ----------- | ------ | ----- |
| Unit (vitest) | 2036 tests / 238 files | 2049 tests / 239 files |
| Integration | (covered separately) | +2 cases in `bp-in-target.test.ts` |
13 new unit cases + 2 new integration cases. All green.
## Decisions logged
- **DE rename**: "Zusammenhänge" over "Trends" — relational meaning,
doesn't collide with the Trends-row title directly above.
- **Section tabs**: kept the scroll-anchored sticky strip and lifted
it above the hero. A full SPA-tab rewrite is deferred (v1.5+).
- **All-time sub-value**: rendered as a third `<span>` inside the
existing `flex gap-3` sub-row so on mobile it wraps naturally below
`7d:` and `30d:` instead of crowding into a single line.
- **Synthetic slope**: BP-tile uses `slope = (pct7 - pct30) / 30` as
the slope-per-day signal. Confidence pinned to 1 because the source
is a deterministic difference, not a regression fit.
+114
View File
@@ -0,0 +1,114 @@
# Wave 3 — Coach polish report (v1.4.22)
Adopts the W1b research output: the Coach now talks like a partner
who sits alongside the user, not a database cursor. Load-bearing
numbers move out of the prose into a collapsible block at the bottom
of every assistant turn. Five atomic commits on `origin/develop`.
## Commits
1. `feat(coach): warm motivational rewrite with evidence-block sentinel`
— system-prompt EN + DE rewrite, PROMPT_VERSION 4.20.2 → 4.22.0,
broadens schema-test regex off the 4.20.x pin.
2. `feat(coach): parse evidence-block sentinel out of streamed prose`
— `---KEYVALUES---` … `---END---` parser, `CoachProvenance.keyValues`
extension, route + persistence + SSE provenance frame wiring,
integration coverage.
3. `feat(coach): collapsible evidence-block disclosure under assistant
bubbles` — `<details>` disclosure with chevron in
`message-thread.tsx`; closed by default; hides when keyValues is
empty; both EN + DE labels.
4. `refactor(coach): drawer polish — Gravatar avatar, disclaimer
move, cog removal` — user bubble pulls `useAuth().user.gravatarUrl`
at the same 32-px dimensions as the Coach avatar with initials
fallback; disclaimer moved out of `coach-input.tsx` into
`sources-rail.tsx`'s footer; settings cog removed cleanly from the
drawer header.
5. `chore(planning): record Wave 3 Coach polish completion` — STATE
tick + this report.
## Sentinel-parse design
Wave 3 ships a text-marker sentinel (`---KEYVALUES---` … `---END---`)
with line-formatted entries (`<label>: <value> [<unit>] (<window>)`)
rather than the fenced-JSON shape W1b sketched. The maintainer
called this directly — robust against accidental Markdown-render
collisions, and easier to scan in raw provider logs.
Hard caps stack three ways:
- 1 KB on the sentinel-block payload (post-`---KEYVALUES---` byte
count). Prompt-injection padding cannot grow the persisted
envelope.
- 8 lines kept after parsing — matches the prompt contract.
- Each line validates against the Zod `coachKeyValueSchema`
(length-capped label / value / unit / window). Malformed lines
drop silently; the route logs a wide-event
`coach.keyvalues.parse_failed` only when the block as a whole is
malformed (missing close sentinel, truncated, or zero valid rows).
When the provider returns prose-only (mock client, refusal path,
legacy provider that hasn't been re-prompted), the parser is a
no-op: prose passes through, no `keyValues` field lands on the
persisted envelope, and the UI hides the disclosure entirely.
## Settings cog
Removed per the maintainer's directive. The placeholder tooltip
("Coach settings arrive in v1.4.21") read as a dead button after
v1.4.21 shipped without delivering that surface. The v1.4.23
candidate is a per-user prompt-tuning panel (tone, verbosity,
fallback-language preference) — when that's ready, the cog returns
with real wiring.
## Concrete prose example
User asks: "Meinst du, ich sollte mehr Sport machen?"
The new prompt produces (synthesised from the prompt's rules + the
embedded few-shot):
> Bewegung sehe ich in deinem Tracking gerade nicht — magst du mir
> kurz erzählen, wie eine typische Woche bei dir aussieht?
> Spaziergänge, Sport, irgendwas Strukturierteres? Dann schauen wir
> gemeinsam mit dem, was ich sehe, ob mehr Sport gerade Sinn ergibt.
No sentinel block (the turn is qualitative, no numbers carry the
answer), so the disclosure is hidden client-side. The persona
("warm, neugierig, zurückhaltend"), the rule-3 invitation pivot, and
the rule-5 open question all fire in three sentences.
## Test count delta
- Unit tests: 2068 → 2085 (+17 — 14 system-prompt assertions, 18
sentinel-parse cases, 4 message-thread disclosure + Gravatar
cases; offset against the disclaimer-test rewrite in coach-input
and sources-rail).
- Integration tests: 5 → 7 (+2 — sentinel round-trip + prose-only
graceful-degrade against the postgres testcontainer).
## Verification gates
- `pnpm typecheck` — clean.
- `pnpm lint` — 13 pre-existing warnings (no new entries).
- `pnpm test --run` — 241 files / 2085 tests green.
- `pnpm test:integration tests/integration/coach-chat.test.ts` —
7 tests green.
## What did not ship
Nothing was punted from the Wave 3 scope. The B3 Gravatar parity
uses initials when the user's email is missing — the lookup is
already server-side in `/api/auth/me`, so there's no extra round
trip on the client. The Coach drawer mobile-tray test was untouched
and still passes after the cog removal.
## What stays as Wave 3 followups
- v1.4.23 candidate: per-user Coach settings surface (tone slider,
language preference). Brings the cog back with real wiring.
- The `mock-client.ts` provider remains prose-only by design — no
changes needed.
- Sentinel format is documented in the prompt body itself; a
future docs-site page on the v1.4.22 release covers the
user-facing disclosure.
+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).
@@ -0,0 +1,108 @@
# Wave 5 reconcile — v1.4.22
Branch: `develop` (10 reconcile commits ahead of `363e73c`).
Date: 2026-05-10.
Inputs: six W5 reviews + product-lead strategic memo.
## Summary
- 0 CRITICAL findings across all reviews.
- 7 HIGH findings — all applied inline.
- ~6 MEDs applied inline (judgment-pick high-leverage).
- Remaining MED + LOW deferred to `.planning/v1422-backlog.md`.
## Commits
| # | SHA | Title | Cross-ref |
|---|-----|-------|-----------|
| 1 | `75ccc01` | fix(coach): graceful fallback when sentinel-only output strips empty | Code-H1 |
| 2 | `ef597bf` | fix(insights): align BD-Zielbereich delta math with comparison window | Code-H2 |
| 3 | `b1ce6eb` | refactor(auth): fold onboarding-cookie write into createSession + destroySession | Sr-H1 |
| 4 | `2075d39` | refactor(api): extract createSseStream helper from chat route | Sr-H2 |
| 5 | `a2e24b1` | a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce) | Design-H1, Design-H3, Code-MED-4, Design-LOW-4 |
| 6 | `651a529` | fix(insights): collapse BP tile to single secondary row at <sm | Design-H2 |
| 7 | `65a8b5a` | fix(coach): pin medical disclaimer at bottom of message thread | Design-H3, Design-M4 |
| 8 | `1631d80` | refactor: simplify-pass apply-yes cleanup | S-01..S-05 |
| 9 | `6c0c69c` | fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race | Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3 (×2), Code-LOW-5 |
| 10 | _this commit_ | docs(planning): record Wave 5 reconcile + v1.4.22 backlog | meta |
## Findings by bucket
### HIGH (7 / 7 applied)
- **Code-H1** ✅ sentinel-only output no longer leaks `---KEYVALUES---` markers. New integration test covers the empty-prose-after-strip branch.
- **Code-H2** ✅ BD-Zielbereich tile compareDelta now matches caption: subtracts `bpInTargetPctPriorMonth` / `bpInTargetPctPriorYear` instead of `bpInTargetPctAllTime`. Two new bp-in-target unit tests + two insights-polish guards.
- **Sr-H1** ✅ `createSession` requires `onboardingPending` parameter; cookie-write fan-out reduced to one site. Three new auth-flow integration tests pin the contract.
- **Sr-H2** ✅ `createSseStream` extracted to `src/lib/sse/create-stream.ts`. Three unit tests pin sync, async, and throw paths.
- **Design-H1** ✅ section nav `aria-current`, `focus-visible` ring, `motion-reduce` gate, `bg-background/95 backdrop-blur`, `-mx-` removed, scrollbar hidden, observer ratio-sorted, `scroll-mt-28` → `scroll-mt-16`.
- **Design-H2** ✅ BP tile `<sm` collapses to a single combined "All-time X% · Δ Y last month" secondary row; `>=sm` keeps the full layout.
- **Design-H3** ✅ medical disclaimer pinned at bottom of message thread (always visible regardless of viewport); rail footer kept for desktop redundancy.
### MED (applied)
- **Sec-MED-1** ✅ `hl_onboarding` cookie `SameSite=Strict`.
- **Sec-MED-2** ✅ `/onboarding` exact-match + subroute guard.
- **Code-MED-2** ✅ dead i18n keys (`coach.settings`, `coach.settingsTooltip`) dropped.
- **Code-MED-3 (Berlin tz)** ✅ `berlinDayKey()` lifted to `src/lib/analytics/berlin-day.ts`; targets sparkline + analytics route share the helper. Four new unit tests for DST + UTC-midnight edge cases.
- **Code-MED-3 (streaming race)** ✅ 150 ms grace window on message-thread suppresses the persisted twin while the streaming bubble is still in flight.
- **Code-MED-4** ✅ IntersectionObserver picks highest-intersection entry; folded into Design-H1 commit.
- **Code-LOW-5** ✅ `queryKeys.dashboardWidgets()` centralised; three call sites migrated.
### MED (deferred)
All other MEDs documented in `.planning/v1422-backlog.md` under "Wave 5 reconcile carry-over":
- Sr-M1..M5 (analytics-route unbounded findMany; targets-route 7-pass sparkline; targets-page render mutation; provenanceFromJson hand-rolled validation; sentinel parser malformed enum).
- Design-M1..M3, M5..M7 (coach evidence aria semantics; sparkline min-points threshold; severity badge eyebrow + i18n; row-fill heuristic; live-preview affordance; sparkline aria sentiment).
- Code-MED-1 already applied; Code-MED-2 already applied; Code-MED-3 already applied (×2); Code-MED-4 already applied.
### LOW (deferred)
All LOWs deferred to backlog: Code-LOW-1..LOW-4, Sec-LOW-1..LOW-4, Design-L1..L5, Sr-L1..L4.
### Simplify (5 / 5 apply-yes applied)
- **S-01..S-05** ✅ all five applied in single `refactor: simplify-pass apply-yes cleanup` commit (`1631d80`).
- **S-M1..S-M4** deferred (maybe-tier).
- **S-N1..S-N6** rejected (per simplify review).
## Test count delta
- Baseline (pre-W5 reconcile): 2097 unit tests across 243 files.
- Final (post-W5 reconcile): **2111 unit tests across 245 files** (+14 unit tests, +2 files).
- Integration tests: 81 → 84 (+3) per the new auth-flow + coach-chat + proxy entries; verified locally (`pnpm test:integration auth-flow` passes 6/6).
New test surfaces:
- `src/lib/sse/__tests__/create-stream.test.ts` (3 tests)
- `src/lib/analytics/__tests__/berlin-day.test.ts` (4 tests)
- `src/lib/analytics/__tests__/bp-in-target.test.ts` (3 new tests for priorMonth/priorYear/null windows)
- `src/app/__tests__/insights-polish.test.ts` (2 new guards for Code-H2)
- `src/__tests__/proxy-onboarding-redirect.test.ts` (2 new guards for Sec-MED-2)
- `tests/integration/coach-chat.test.ts` (1 new test for sentinel-only leak)
- `tests/integration/auth-flow.test.ts` (3 new tests for createSession/destroySession cookie contract)
## Verification gates run between commits
For every commit:
- `pnpm typecheck` (clean — 0 errors)
- `pnpm test --run` (passes after each change; final 2111/2111)
- `pnpm lint` (15 pre-existing warnings, 0 errors)
For HIGH 1 + HIGH 3 + Sec-MED-2 (per brief):
- `pnpm test:integration auth-flow` 6/6 passes
- `pnpm test --run src/__tests__/proxy-onboarding-redirect.test.ts` 9/9 passes
## Items the maintainer should weigh in on
1. **CSP `connect-src` for `https://chatgpt.com`** is gated to `/settings/ai/**` only (per existing proxy.ts comment). The Coach SSE route hits OpenAI/Anthropic via the provider runner, which runs server-side — no browser fetch — so this is correct. Calling out only because the W5 simplify review flagged proxy.ts as out-of-scope and the cookie attribute changed; verify the connect-src isn't accidentally re-broadened by a future MED-2 follow-up.
2. **Insights page split deferred to v1.5 P5** per the product-lead memo. The W5 senior-dev review's L3 (`InsightsSectionNav` extraction) is a natural sub-task at that point. Leaving the page at 1700+ LOC for one more cycle is the right call but worth a v1.4.23 reminder when the next polish PR opens.
3. **Sr-M5 sentinel parser malformed-enum** carries the highest signal-to-effort ratio of the deferred MEDs (4-state enum, ~30 LOC, tightens ops observability before iOS volume lands). Suggest pulling forward to v1.4.23 if room.
4. **The `/api/auth/me` resync write** stays as a fall-back per the original v1.4.22 C4 design. Sec-LOW-1 calls it out only as a "future cargo-cult" risk — not an actual bug. Optional one-line code comment about not growing the GET-side mutation. Filed in backlog as LOW.
## Anything I couldn't reconcile
- All 7 HIGH applied successfully. No unresolved blockers.
- The Code-LOW-2 colon-bearing label parser test was deferred (LOW + low-priority). The defensive contract is documented in the prompt few-shots; an explicit constraint comment in the prompt header is the cleanest fix when v1.4.23 next-touches the prompt.
+46
View File
@@ -3,6 +3,52 @@
Carry-over from v1.4.20 marathon foundation phases. Pick from this list
when v1.4.21 starts.
## Closed in v1.4.22
The Wave-4 cleanup pass closed the following items. See
`.planning/phase-W4-report.md` for full SHAs + reasoning.
- **C1** Zielwerte sparkline + Δ-vs-last-month — landed in commit 1
- **C2** api-tokens 5th-attempt scrollbar fix — landed in commit 2
- **C3** Coolify `?force=true` + howto for the UI checkbox — landed in
commit 3
- **C4** AuthShell post-hydration redirect → moved to proxy.ts — landed
in commit 4
- **C5** node-26-alpine — PR #162 closed (no Dockerfile bump; Next.js
16 still pins node 22 LTS and node:26-alpine ships without corepack)
- **F-32** triple help blocks on /settings/dashboard — landed in
commit 5
- **F-33** mood subtitle ampersand → "und" / "and" — landed in commit 5
- **F-34** measurements subtitle ampersand → "und" / "and" — landed in
commit 5
- **F-35** Send-test button label inconsistency — unified on the
shorter "Send test" / "Test senden" — landed in commit 5
- **F-44** `LOKI_ENDPOINT` env-var leakage in user-facing copy — landed
in commit 5
- **F-45** admin/users column header "Users" → "Name" (new
`admin.userName` key) — landed in commit 5
- **F-47** /notifications event-column header collision with the page
title — new `notifications.eventColumn` key — landed in commit 5
- **F-50** DE error-toast tone — "Bitte erneut versuchen" → "Bitte
versuche es erneut" across six keys — landed in commit 5
- **F-58** admin/reminders icon swap (Clock → Bell to match the
admin-shell nav) — landed in commit 5
- **F-62** DE dashboard `greetingSalutation` ("Hi" → "Hallo, {name}")
— landed in commit 5
- **D-CR-M-03** formatTokenName regex broadened to non-Z ISO offsets
— landed in commit 5
- **D-DSGN-M-04** moodLog "Copy" button uses `common.copy` instead of
stripping `!` off `common.copied` — landed in commit 5
- **FX 1** 191 `Marc` source-comment references swept (test fixtures
kept) — landed in commit 6
- **FX 2** DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
normalised to English-only — landed in commit 6
- **FX 3** `CLAUDE.md` renamed to `CONTRIBUTING-AI.md`; `AGENTS.md`
retained for multi-agent compatibility — landed in commit 6
The remaining MED + LOW items below either deferred to v1.4.23 (most
of the heavy reviewer findings) or stay open as a hygiene PR.
## Phase D — v1.4.20 reconcile carry-over
Items raised by the six-reviewer Phase-D pass that were either too
+68
View File
@@ -0,0 +1,68 @@
# v1.4.22 backlog
Items deferred from the W5 reconcile pass. Carried into v1.4.23 +.
## Wave 5 reconcile carry-over
### Code review
- **Code MED-1** (`src/app/api/insights/targets/route.ts:199-216`): `sparklinePoints()` once-per-day bucket now uses Berlin tz (lifted to `src/lib/analytics/berlin-day.ts` in W5 reconcile). DST-boundary edge-case unit test added. `analytics/route.ts` now imports the shared helper. — APPLIED in W5 reconcile, kept here for trace.
- **Code MED-4** (`src/app/insights/page.tsx:1729-1747`): IntersectionObserver picks the highest-intersection entry now. — APPLIED in W5 reconcile (folded into Design-H1 commit), kept here for trace.
- **Code LOW-1** (`src/components/insights/trends-row.tsx:148`): `<MoodChart title={…} />` self-resolves a localised title; the explicit prop tightens coupling. Drop the `title` prop on all three TrendsRow callsites OR add a comment. Cosmetic only.
- **Code LOW-2** (`src/lib/ai/coach/keyvalues.ts:53`): `parseKeyValueLine` regex uses the first `:` as label/value boundary. A future prompt rev that adds a time-of-day pin would break (`"Mon 6 May, 7:30 reading: 142/88"`). Add a parser test for a colon-bearing label as a regression pin OR document the constraint in the prompt explicitly.
- **Code LOW-3** (`src/app/api/insights/chat/route.ts:284-291`): sentinel parse failure logged with `kept` count but not the prompt-version-vs-malformed-cause breakdown. Extend `SentinelParseResult` with `reason: "ok" | "no_close_marker" | "truncated" | "empty_block"`.
- **Code LOW-4** (`src/app/targets/page.tsx:272-273`): `Math.min(...points)` / `Math.max(...points)` spread-call patterns can blow stack at ~10k arguments. Replace with `for` loop or `points.reduce(...)`.
- **Code LOW-5** (`src/lib/query-keys.ts`): `["user", "dashboardWidgets"]` centralised in `queryKeys.dashboardWidgets()` in W5 reconcile, three call sites migrated. — APPLIED.
### Security review
- **Sec MED-1** (`src/lib/auth/session.ts:32`): `hl_onboarding` cookie `SameSite=Lax` → `Strict`. — APPLIED in W5 reconcile.
- **Sec MED-2** (`src/proxy.ts:28-33` + `:152`): `/onboarding` `startsWith` match tightened to exact + subroute. — APPLIED in W5 reconcile.
- **Sec LOW-1** (`/api/auth/me`): GET that mutates state (cookie write) on every call. Idempotent; review left as-is, optional comment about not growing.
- **Sec LOW-2** (Coach few-shots): synthetic numeric values baked into the system prompt. Optional `WARNING` comment so a future contributor doesn't paste real readings.
- **Sec LOW-3** (`src/app/api/insights/chat/route.ts:211-220`): transcript builder flattens role labels into a single user prompt; user-self-injection only. v1.4.23 candidate: switch to structured chat-completion payload.
- **Sec LOW-4** (`src/lib/ai/coach/keyvalues.ts:147-180`): truncated/closeMissing booleans not surfaced to wide-event meta. Extend with `truncated` / `closeMissing` for ops visibility.
### Design review
- **Design M1** (`coach-panel/message-thread.tsx:259-307`): coach evidence disclosure has no `aria-controls` / `aria-expanded` semantics; `<summary>` reports without expanded state in iOS Safari ≤ 17.3. Wrap the summary in a 36-px-min hit zone, add `aria-expanded` mirror via `useState` + `onToggle`.
- **Design M2** (`src/app/targets/page.tsx:264-303`): Sparkline has no min-points threshold guard — 3-4 points spread over 30 days reads "lots of activity". Either require `length >= 7` before painting, or render at `stroke-opacity:0.5` for `length < 10` plus a `{n} of 30 days logged` caption.
- **Design M3** (`src/components/insights/recommendation-card.tsx:328-335`): severity badge uppercase eyebrow re-introduces the v1.4.20 M4 marketing pattern AND surfaces the internal severity enum (`urgent` / `suggestion`) — no i18n. Drop `tracking-wide uppercase`, replace `{norm.severity}` with `{t(\`insights.recommendation.severity.\${norm.severity}\`)}`. Add EN/DE keys.
- **Design M4** (Coach disclaimer reach): pinned the disclaimer at the bottom of the message thread in W5 reconcile (Design-H3 fix). — APPLIED.
- **Design M5** (`src/app/insights/page.tsx:1070-1078`): row-fill rule on the medication-correlation row uses `showMoodSection` heuristic. Either hide the BP-medication card entirely when `bpMedicationScatterData.length < 5` (let mood-vs-BP take 100 %) or restore the fixed 50/50 with per-card empty state.
- **Design M6** (`dashboard-layout-section.tsx:204-241`): comparison-toggle has no live-preview affordance. Add a tiny `<TrendCard>` preview swatch under the picker.
- **Design M7** (`src/app/targets/page.tsx:286-301`): SVG sparkline `aria-hidden` skips the only "things changing" signal for SR users. Add `aria-label` to the `<TrendIcon>` parent OR move the sentiment into the delta caption prose.
- **Design L1** (i18n voice asymmetry on `coach.evidenceLabel`): EN ("What I'm looking at") declarative; DE ("Worauf bezieht sich das?") curious. Decide on a unified voice.
- **Design L2** (`coach-panel/message-thread.tsx:286-304`): React `key={\`\${kv.label}-\${idx}\`}` allows duplicate-label collisions. Use a hash of `\${label}-\${window}-\${value}`.
- **Design L3** (`targets/page.tsx:288`): sparkline `preserveAspectRatio="none"` distorts y vs x. Either keep + accept, or fix Y-range to the target range.
- **Design L4** (`scroll-mt-28` overshoot): tuned to `scroll-mt-16` in W5 reconcile (Design-H1 commit). — APPLIED.
- **Design L5** (api-tokens table date wrap): cells wrap to two lines on 1024 px. Either short-format `formatDate` + tooltip, or push `<col>` width to 14 %.
### Senior-dev review
- **Sr H1** (createSession cookie fan-out): folded into createSession in W5 reconcile. — APPLIED.
- **Sr H2** (chat route SSE construction duplicated 3×): extracted `createSseStream` helper in W5 reconcile. — APPLIED.
- **Sr M1** (analytics route fetches ALL BP measurements ever, twice): bound by horizon (5 years) OR materialise a per-user `bp_target_aggregate` row refreshed on measurement-write.
- **Sr M2** (targets route's 7-pass sparkline computation): collapse to a single Postgres-side `GROUP BY date_trunc('day', measured_at), type`.
- **Sr M3** (targets page mutates server response inside render): wrap the glucose-target conversion in `useMemo([data, t, displayGlucoseUnit])` to keep refs stable.
- **Sr M4** (`provenanceFromJson` hand-rolls keyValues validation): replace 35 lines of manual `typeof` checks with `coachKeyValueSchema.array().safeParse(parsed.keyValues)`. Same `safeParse` lift fixes the windows / metrics enum-membership gap.
- **Sr M5** (sentinel parser conflates 3 distinct malformed conditions): four-state enum (`"ok" | "truncated" | "unclosed" | "empty"`) so ops dashboards can distinguish the cases.
- **Sr L1** (`Sparkline` `preserveAspectRatio="none"`): same as Design L3.
- **Sr L2** (`setOnboardingPendingCookie` `async` but does no async work): could be sync if `cookies()` resolved by caller. v1.5 cleanup.
- **Sr L3** (`InsightsSectionNav` re-creates IntersectionObserver on every mount): single mount per page, so the practical cost is negligible — observer should move with the component on extraction.
- **Sr L4** (`as TargetItem` cast in `targets/route.ts:281`): the BP entry ships sys-only data without typing it. Pre-existing.
### Simplify review
- **S-01..S-05**: applied in W5 reconcile (single commit). — APPLIED.
- **S-M1..S-M5**: maybe-tier — leave for now; revisit when adjacent work re-touches the surfaces.
### Product Lead memo (cross-link)
- **Insights page split** held to v1.5 P5 (Apple Health work edits the same sub-trees).
- **Pearson p-value normal approximation at low df**: 30-LOC incomplete-beta or raise gate to df >= 20. Pull in v1.5 P0 before auto-discovery ships.
- **Coach token-budget UX**: 25 000 tokens/user/day = ~13 turns. iOS surface in v1.5 will multiply this. Replace generic 429 with localised "limit reached, resets at 00:00 UTC".
- **Conversation persistence eviction policy**: auto-archive after 90 days, hard-delete after 365. v1.5 P3 latest.
- **OpenAPI spec drift CI guard**: pull forward to v1.4.23 if possible.
- **CoachDrawer `key={prefill}` weaponising React keys (Sr-HIGH-4)**: heavy refactor; v1.5 P3 latest.
- **Cross-user feedback aggregation cron** (P6 in product-lead memo): wired alongside v1.5 P3 once usage volume materialises.
+385 -433
View File
@@ -1,5 +1,202 @@
# Changelog
## [1.4.22] — 2026-05-10
### Added
- **Per-target sparkline + Δ-vs-last-month caption on the Targets /
Zielwerte page.** Each `<TargetCard>` grew a 30-day inline SVG
sparkline beneath the range bar plus a localised "Δ −2.3 kg vs.
last month" caption. The API ships `points30d` and
`deltaVsLastMonth` per target; both null when either window has
fewer than 3 readings so cold-start accounts don't paint a
misleading flat trace. BMI piggybacks on the weight series so its
sparkline shares the range bar's y-axis.
- **Sticky section navigation above the Insights hero.** A pinned
strip (Allgemein / Blutdruck / Gewicht / Puls / Stimmung in DE;
General / Blood Pressure / Weight / Pulse / Mood in EN) lifts the
section tabs above the hero so they stay visible during scroll.
Active section tracks the highest-intersection observed entry;
`aria-current`, focus-visible ring, and `motion-reduce` gating
ship from day one.
- **Comparison-overlay as a single global preference under Settings
→ Dashboard.** The on-surface `<CompareToggle />` retired from
`/insights`; the canonical picker has lived in Settings →
Dashboard since v1.4.16 phase B8 and every chart already consumed
it. Two surfaces for the same concept violated the
no-split-Settings rule.
- **Collapsible evidence disclosure under each Coach assistant
message.** Numbers move out of the prose into a
`---KEYVALUES---` … `---END---` sentinel block that the route
parses out server-side and renders as a "Worauf bezieht sich
das?" / "What I'm looking at" `<details>` disclosure under each
assistant turn. Closed by default, hidden when no key-values came
back. Hard caps on the sentinel (1 KB payload, 8 lines max,
per-line Zod) so a prompt-injection attempt can't grow the
persisted envelope.
- **User avatar parity with the Coach avatar.** The Coach drawer's
user-side bubble used a smaller initials avatar than the Coach's
gradient one. The user avatar now reuses the existing
`gravatarUrl` field from `/api/auth/me` at the same dimensions
as the Coach avatar; initials fall back when no Gravatar is
configured.
### Changed
- **Coach prompt rewrite — warm, motivational-interviewing tone
with prose-first responses + evidence collapsible.** PROMPT_VERSION
ratchets 4.20.2 → 4.22.0 (first minor-digit bump in v1.4.x). The
Coach used to open every reply with a number — clinical,
database-cursor energy. The new persona is "warm, neugierig,
zurückhaltend": a partner sitting alongside the user, not pushing
data. Numbers move into the collapsible evidence block; the prose
reads like a real conversation. Persona + sentinel land together
because either alone is incomplete.
- **BP-in-target headline re-anchored to the last-30-day window.**
Fourth attempt at this metric. v1.4.19 routed the headline to
`allTime` to fix the algorithmic 50/50/50 pin; v1.4.22 re-anchors
to `windows.last30Days?.pct` and surfaces all-time as a sub-row,
because v1.4.19's fix was emotionally wrong — the headline became
the slowest-moving aggregate possible, punishing a user who put
in real recent work. The tile also gets a synthesised trend arrow
(slope from 7d/30d delta), a 7-day-trend chip, and a
comparison-overlay caption.
- **"Muster" renamed to "Zusammenhänge" (DE) / "Patterns" renamed
to "Relationships" (EN).** The picked-bucket-of-correlations row
read like an autopsy. The new label sits closer to what the row
actually shows. Picked "Zusammenhänge" over "Trends" because the
row directly above already uses Trends.
- **Onboarding redirect for users with `null onboardingCompletedAt`
moves to server-side enforcement in `proxy.ts`.** The previous
post-hydration redirect inside `<AuthShell>` `useEffect` produced
a brief dashboard flash on incomplete onboarding. The proxy runs
in the Edge runtime so the auth routes mirror
`onboardingCompletedAt` into a non-httpOnly `hl_onboarding`
cookie; the proxy short-circuits before hydration. Tampering only
skips a UX hint and never bypasses a server check.
- **`setOnboardingPendingCookie` folded into `createSession()`.**
Issuing a session without onboarding state used to require two
call sites to stay in sync; the helper now takes
`onboardingPending` as a required parameter so the contract is
type-impossible to break.
### Fixed
- **Raw `metric:<TYPE>` token leaks in recommendation prose.**
`<RecommendationCard>` text is now wrapped in
`stripChartTokens()`. The leak traced to a single missing call
on the recommendation path; chart tokens never belonged in user
copy.
- **DE locale `componentMood`, `componentBp`, `componentCompliance`
rendered English nouns in the German bundle.** Four Health-Score
component labels normalised to `Stimmung`, `Blutdruck`,
`Einnahmetreue`, `Gewicht`. The i18n-integrity test pins the
contract.
- **Admin / API tokens horizontal scrollbar at desktop + iPad-mini
viewports (5th attempt).** A live Playwright probe confirmed
`whitespace-nowrap` on the date `<td>`s was the residual
culprit. The two classes are gone; date + time wraps to two
lines on narrow viewports. The earlier four fixes had targeted
the wrong layout layer.
- **Sentinel-only / malformed `---KEYVALUES---` block.** When the
model emits a sentinel-only or malformed envelope, the fallback
now produces a polite invitation ("I'd like to look at this with
you — could you share which window you want me to focus on?")
instead of surfacing the raw marker. A new integration test
covers the empty-prose-after-strip branch.
- **BD-Zielbereich tile delta math period-aligned with the
comparison window.** The tile's compareDelta was subtracting
`bpInTargetPctAllTime` while the caption said "vs last month",
which produced numbers the caption couldn't justify. It now
subtracts `bpInTargetPctPriorMonth` / `bpInTargetPctPriorYear`
to match. Two new bp-in-target unit tests + two
insights-polish guards.
- **Coach drawer settings cog removed.** The cog was a dead button
in v1.4.21; per-user prompt-tuning is deferred to v1.4.23. No
dead buttons in this release.
- **Coach disclaimer pinned at the bottom of the message thread.**
Clinical-adjacent UI must not gate the disclaimer behind a
chevron tray; the disclaimer now stays visible at the bottom of
the message thread on every viewport, and the rail-footer
duplicate kept for desktop redundancy.
- **`hl_onboarding` UX-hint cookie now `SameSite=Strict`.** A
cross-site request couldn't usefully exfiltrate the cookie
(no auth value, only an onboarding state flag) but `Lax` was
still over-permissive. Strict aligns with the cookie's
same-site-only consumer.
- **`PUBLIC_PATHS` exact-match guard against future subroute
prefix bypasses.** `/onboarding` is now exact-match +
explicit-subroute, not a `startsWith` check. Two new proxy
guards pin the contract.
- **`targets/route.ts` daily buckets keyed in Europe/Berlin.**
The targets sparkline was the last analytics surface still
bucketing in UTC, which produced a one-day-off trace for users
in CEST. `berlinDayKey()` lifted to a shared
`src/lib/analytics/berlin-day.ts` helper; four new DST + UTC-
midnight edge-case unit tests.
- **Streaming bubble vs persisted-twin race window.** The
150ms grace window suppresses the persisted twin while the
in-flight streaming bubble is still rendering, so the thread
never paints two copies of the same reply for a frame.
- **Sticky section navigation a11y polish.** `aria-current` on
the active section; focus-visible ring on keyboard navigation;
`motion-reduce` honoured for smooth-scroll; the glow-bleed
fixed via `bg-background/95 backdrop-blur`; the mobile cliff
tightened from `scroll-mt-28` to `scroll-mt-16` so the strip
no longer eats the heading at 280px viewports.
- **BP tile mobile density at <sm.** All-time + delta now
collapse into one secondary line on small viewports; the
full layout returns at `>=sm`.
### Refactor
- **`createSseStream` shared helper extracted from the chat
route.** Preparation for v1.5 iOS streaming endpoints, which
will reuse the SSE primitives. Three unit tests pin sync,
async, and throw paths. Source: `src/lib/sse/create-stream.ts`.
- **`<TokenStatusBadge>` extracted from desktop + mobile
api-token surfaces.** The badge logic was duplicated verbatim
in two layouts; one component, two consumers.
- **Five simplify apply-yes items in one commit.** `canSubmit`
collapse, weekly-report `<Button>` dedup, and three smaller
cleanups identified in the W5 simplify pass.
### Operational / hygiene
- **Coolify image-digest auto-deploy.** Instructions for the
one-time UI-toggle ("Watch image registry for new digests")
live at `.planning/coolify-auto-deploy-howto.md`. Future
releases should drop the host-side retag fallback the moment
the toggle is on.
- **191 maintainer-name references in `src/` source comments
swept** (FX carry-over from v1.4.20). Test fixtures kept as
opaque test data.
- **DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
normalised to English-only.** Per the English-only voice
rule.
- **`CLAUDE.md` filename retired (FX carry-over)** so the
filename is no longer AI-vendor-specific; `CONTRIBUTING.md`
reference updated. `AGENTS.md` stays for multi-agent
compatibility.
### Deferred to v1.4.23
See `.planning/v1422-backlog.md` for the full carry-over list.
Highlights: sentinel parser malformed-enum hardening (Sr-M5);
analytics-route unbounded `findMany` paging; targets-route
7-pass sparkline coalesce; `CoachDrawer key={prefill}`
controlled-prop refactor (Sr-HIGH-4); per-user prompt-tuning
surface; medication_schedules.days_of_week schema-drift cleanup.
### Deferred to v1.5 (iOS push)
See `.planning/phase-W5-v1422-product-lead-review.md` for the
full v1.5 plan. Headline: iOS native client + Apple Health
ingest contract (HRV, Sleep, Resting HR, Steps, BodyFat,
Glucose); per-metric APNs alerts; OpenAPI spec drift CI gate;
Coach extension for the new measurement types
(PROMPT_VERSION 4.22.0 → 5.0.0).
## [1.4.21] — 2026-05-10
### Fixed
@@ -549,395 +746,189 @@
### Added
- **Backups: vollständig.** Die Sicherung am Sonntag früh ist jetzt
nur noch der Anfang. `/admin/backups` lässt jeden Snapshot als
`.json` herunterladen, neue Snapshots hochladen und — hinter einer
dreifachen Bestätigung („RESTORE“ tippen + Dialog + Confirm) —
einen Snapshot direkt in die Datenbank zurückspielen. Jede
Operation (Run, Download, Upload, Restore inkl. Start/Failure)
landet im Audit-Log mit Akteur und Ziel-Snapshot.
_Backups become a full lifecycle. The Sunday-morning snapshot is
no longer the end of the road. `/admin/backups` lets you download
any snapshot as `.json`, upload a new one, and — behind a
triple-confirm gate (type “RESTORE” + dialog + confirm) — restore
a snapshot straight into the database. Every operation (run,
download, upload, restore including start/failure) is recorded in
the audit log with actor and target snapshot._
- **Erfolge-Seite (`/achievements`) und Dashboard-Karte.** Dedizierte
Seite zeigt freigeschaltete und gesperrte Erfolge inklusive
Fortschrittsbalken (`{aktuell} / {ziel}`), gruppiert nach
Kategorie (Medikation, Vitalwerte, Sicherheit, Engagement). Auf
dem Dashboard sitzt eine zuschaltbare „Letzte Erfolge“-Karte; die
Sichtbarkeit lässt sich in den Layout-Einstellungen umschalten.
_New `/achievements` page lists locked + unlocked achievements
with progress bars (`{current} / {target}`), grouped by category
(medication, vitals, security, engagement). The dashboard gains a
toggleable “Recent achievements” card; visibility lives in the
Layout settings._
- **Onboarding-Tour für Erstnutzer.** Beim ersten Dashboard-Besuch
läuft ein Spotlight-Walk-Through über die Tile-Strip, das
Quick-Add-Menü, Insights, Integrationen und Erfolge. Skippable mit
Esc, Tastatur-navigierbar, `prefers-reduced-motion` respektiert.
Erneut anstoßen jederzeit unter Settings → Account → „Tour
wiederholen“.
_New onboarding tour for first-run users. A spotlight walk-through
on first dashboard load points out the tile strip, quick-add menu,
insights, integrations, and achievements. Skippable with Esc,
fully keyboard-navigable, honors `prefers-reduced-motion`. Replay
the tour any time from Settings → Account._
- **Doctor-Report v2.** Vor dem PDF-Generieren öffnet sich ein
Dialog mit konfigurierbarem Zeitraum (Voreinstellungen 90 Tage / 6
Monate / 12 Monate, manuell editierbar, max. 2 Jahre) und einem
optionalen Praxis-Namen, der auf der Titelseite des PDFs
erscheint. Der Praxis-Name wird als Nutzervorgabe gespeichert.
_Doctor-report v2. Before the PDF is generated a dialog asks for
the date range (presets 90 days / 6 months / 12 months, manually
editable, max 2 years) and an optional practice name that appears
on the PDF cover page. The practice name is persisted as a user
preference._
- **Auto-Deploy nach GHCR-Push.** Sobald die Docker-Publish-Action
ein neues `:latest`-Image hochgeladen hat, ruft sie den
Coolify-Deploy-Webhook auf. Coolify pingt anschließend einen
internen Endpoint mit dem Ergebnis; Erfolg/Fehler/Unbekannt
landen im Audit-Log, persistente Failures triggern eine
Telegram-Benachrichtigung an alle Admins. Kein manuelles
Force-Pull mehr nötig.
_Auto-deploy on GHCR push. Once the Docker publish action ships a
new `:latest`, it calls the Coolify deploy webhook. Coolify in
turn pings an internal endpoint with the result; success / failure
/ unknown all land in the audit log; persistent failures send a
Telegram alert to every admin. No more manual force-pull on the
host._
- **Empty-States überall.** Brandneue Accounts und leere Listen
bekommen jetzt überall einen sinnvollen Empty-State (Icon +
Beschreibung + Call-to-Action), wo vorher nur weiße Fläche stand.
Betroffen: Admin-Tabellen (Users, Backups, Login-Overview,
API-Tokens, Feedback, Audit-Preview), Messungs-, Stimmungs-,
Medikamenten- und Erfolge-Listen, Insights-Top-Level + BMI-ohne-
Größe, Dashboard für komplett leere Accounts.
_Empty states everywhere. Brand-new accounts and empty lists now
always show a sensible empty state (icon + description + CTA)
where there used to be just white space. Coverage: admin tables
(users, backups, login-overview, api-tokens, feedback,
audit-preview), measurement / mood / medication / achievement
lists, the insights top-level + BMI-without-height view, and the
dashboard for fully empty accounts._
- **Status-Karten für Notification-Channels.** Settings →
Benachrichtigungen zeigt für jeden Kanal (Telegram, ntfy, Web
Push) den aktuellen Status (verbunden / fehlerhaft / deaktiviert),
letzten Erfolg, letzten Fehler, Counter aufeinanderfolgender
Fehlversuche und ggf. Deaktivierungsgrund. Buttons: „Wieder
aktivieren“ (nur wenn auto-deaktiviert) + „Test-Nachricht
senden“.
_Notification channel status UI. Settings → Notifications now
shows per-channel (Telegram, ntfy, Web Push) the current state
(connected / error / disabled), last success, last failure,
consecutive-failure counter, and disable reason if any. Buttons
for “Re-enable” (only when auto-disabled) and “Send test”._
- **Status-Karten für Withings + moodLog.** Settings → Integrationen
zeigt für beide Provider Verbindungsstatus, letzten Sync, letzten
Fehler und einen Reauth-Hinweis, sobald ein Refresh-Token
abgelehnt wurde. Auf persistente Fehler (≥ 3 in Folge) bekommen
Admins eine Telegram-Benachrichtigung, sofern der Kanal aktiviert
ist.
_Withings + moodLog integration status UI. Settings → Integrations
shows per-provider the connection state, last sync, last error,
and a reauth hint as soon as a refresh-token was rejected. After
persistent failures (≥ 3 in a row) admins receive a Telegram alert
if the channel is enabled._
- **Top-Tiles unabhängig wählbar.** In den Layout-Einstellungen lässt
sich für jede Metrik die Sichtbarkeit der Trend-Kachel oben und
des Charts unten getrennt steuern. Bestehende Layouts behalten ihr
bisheriges Verhalten (eine Schaltfläche steuert beide), bis du
explizit den neuen Schalter umlegst.
_Top dashboard tiles selectable. The layout settings now expose a
separate toggle for each metric’s upper-row tile and lower-row
chart. Existing saved layouts keep their previous behaviour (one
switch controls both surfaces) until you explicitly flip the new
toggle._
- **7-Tage-Trend statt 7-Tage-Schnitt.** Auf jeder Tile-Kachel zeigt
ein farbiger Delta-Indikator `(±N.N)` neben dem Wert die
metrik-bewusste Veränderung der letzten 7 Tage gegenüber den
davor. Label umbenannt von „7-Tage-Schnitt“ auf „7-Tage-Trend“.
_7-day-trend instead of 7-day-average. Each tile gains a coloured
delta indicator `(±N.N)` next to the value, showing the
metric-aware change of the last 7 days vs. the prior 7. Label
renamed from “7d average” to “7d trend”._
- Backups become a full lifecycle. The Sunday-morning snapshot is no
longer the end of the road. `/admin/backups` lets you download any
snapshot as `.json`, upload a new one, and — behind a triple-confirm
gate (type “RESTORE” + dialog + confirm) — restore a snapshot straight
into the database. Every operation (run, download, upload, restore
including start/failure) is recorded in the audit log with actor and
target snapshot.
- New `/achievements` page lists locked + unlocked achievements with
progress bars (`{current} / {target}`), grouped by category (medication,
vitals, security, engagement). The dashboard gains a toggleable “Recent
achievements” card; visibility lives in the Layout settings.
- New onboarding tour for first-run users. A spotlight walk-through on
first dashboard load points out the tile strip, quick-add menu,
insights, integrations, and achievements. Skippable with Esc, fully
keyboard-navigable, honors `prefers-reduced-motion`. Replay the tour any
time from Settings → Account.
- Doctor-report v2. Before the PDF is generated a dialog asks for the date
range (presets 90 days / 6 months / 12 months, manually editable, max 2
years) and an optional practice name that appears on the PDF cover page.
The practice name is persisted as a user preference.
- Auto-deploy on GHCR push. Once the Docker publish action ships a new
`:latest`, it calls the Coolify deploy webhook. Coolify in turn pings an
internal endpoint with the result; success / failure / unknown all land
in the audit log; persistent failures send a Telegram alert to every
admin. No more manual force-pull on the host.
- Empty states everywhere. Brand-new accounts and empty lists now always
show a sensible empty state (icon + description + CTA) where there used
to be just white space. Coverage: admin tables (users, backups,
login-overview, api-tokens, feedback, audit-preview), measurement / mood
/ medication / achievement lists, the insights top-level +
BMI-without-height view, and the dashboard for fully empty accounts.
- Notification channel status UI. Settings → Notifications now shows
per-channel (Telegram, ntfy, Web Push) the current state (connected /
error / disabled), last success, last failure, consecutive-failure
counter, and disable reason if any. Buttons for “Re-enable” (only when
auto-disabled) and “Send test”.
- Withings + moodLog integration status UI. Settings → Integrations shows
per-provider the connection state, last sync, last error, and a reauth
hint as soon as a refresh-token was rejected. After persistent failures
(≥ 3 in a row) admins receive a Telegram alert if the channel is
enabled.
- Top dashboard tiles selectable. The layout settings now expose a
separate toggle for each metric’s upper-row tile and lower-row chart.
Existing saved layouts keep their previous behaviour (one switch
controls both surfaces) until you explicitly flip the new toggle.
- 7-day-trend instead of 7-day-average. Each tile gains a coloured delta
indicator `(±N.N)` next to the value, showing the metric-aware change of
the last 7 days vs. the prior 7. Label renamed from “7d average” to “7d
trend”.
### Changed
- **`/admin`-Übersicht neu gestaltet.** Statt einer Section-Grid
zeigt die Übersicht jetzt eine Audit-Log-Vorschau (letzte
Einträge, Akteur, Zielressource) und einen
System-Status-Snapshot. Die Section-Grid wandert in die Sidebar.
_The `/admin` overview replaces the section grid with an
audit-log preview (recent entries, actor, target resource) and a
system-status snapshot. The section grid moved into the sidebar._
- **Sidebar-Admin-Submenu kontextbewusst.** Die Admin-Untereinträge
klappen nur noch auf, wenn man tatsächlich auf einer
`/admin/*`-Route ist. Auf allen anderen Seiten bleibt die
Admin-Gruppe eingeklappt.
_The sidebar Admin sub-items only expand when you are actually on
an `/admin/*` route. Everywhere else the Admin group stays
collapsed._
- **Stimmung-Chart aggregiert wie die anderen Metriken.** Über 90
Tage wechselt das Stimmung-Diagramm automatisch auf wöchentliche
Aggregation, über 730 Tage auf monatliche — analog zu Gewicht und
Blutdruck. Im Header zeigt ein Chip die aktive Aggregationsstufe.
_The mood chart now auto-aggregates: weekly past 90 days, monthly
past 730 days — same thresholds as weight and BP. A chip in the
chart header shows the active aggregation level._
- **KI-Insights mit harten Anti-Halluzinations-Leitplanken.** Die
Provider-Antwort wird gegen ein striktes Zod-Schema geprüft
(Zusammenfassung, Empfehlungen mit Pflichtfeld `metricSource`,
Zitate, Warnungen). Empfehlungen, die Datenpunkte zitieren, die
nicht in der Snapshot-Liste stehen, werden abgewiesen. Der
Wrapper retried genau einmal mit korrigierender Systemnachricht;
scheitert auch der zweite Versuch, kommt 422 zurück statt
unsauberen Ausgaben. Slug-Drift im Codex-Backend (z. B. Wechsel
`gpt-5-codex` → `gpt-5.3-codex`) ist jetzt durch eine
konfigurierbare Fallback-Kette + 1-Stunden-Cache abgefedert; alle
Slugs durch → strukturierter 503 mit `attempted[]`-Liste.
_AI insights gain hard anti-hallucination guardrails. Provider
responses are validated against a strict Zod schema (summary,
recommendations with mandatory `metricSource`, citations,
warnings). Recommendations citing data points not present in the
snapshot are rejected. The wrapper retries exactly once with a
corrective system message; if the second attempt also fails, it
returns 422 instead of unsanitised output. Codex-backend slug
drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now cushioned by
a configurable fallback chain + 1-hour positive cache; if every
slug fails the route returns a structured 503 with the
`attempted[]` list._
- **`/admin/api-tokens`-Tabelle mobil.** Auf schmalen Viewports
blenden Spalten gestaffelt aus (zuletzt benutzt, erstellt am,
Owner) und der Owner-Username wandert inline neben den Token-
Namen, damit beim Ausblenden keine Information verloren geht.
_The `/admin/api-tokens` table is responsive on mobile. Narrow
viewports gate columns behind breakpoints (last-used, created-at,
owner) and the owner username falls back inline next to the token
name so hiding the column never drops data._
- **Stimmung-Kachel mobil.** Auf schmalen Viewports zeigt die
Mood-Karte oben jetzt nur noch die große Score-Zahl + Label statt
der doppelten Darstellung von vorher.
_Mood tile on mobile shows only the large score number + label,
no more doubled rendering._
- **Quick-Add-Submenu eindeutiger.** Die beiden Einträge im
Quick-Add-Menü heißen jetzt „Messung erfassen“ und „Stimmung
erfassen“ (DE) bzw. „Log measurement“ und „Log mood“ (EN) —
vorher beide schlicht „Hinzufügen“.
_Quick-add submenu disambiguated. The two entries are now called
“Messung erfassen” / “Stimmung erfassen” (DE) and “Log
measurement” / “Log mood” (EN) — previously both simply “Add”._
- The `/admin` overview replaces the section grid with an audit-log
preview (recent entries, actor, target resource) and a system-status
snapshot. The section grid moved into the sidebar.
- The sidebar Admin sub-items only expand when you are actually on an
`/admin/*` route. Everywhere else the Admin group stays collapsed.
- The mood chart now auto-aggregates: weekly past 90 days, monthly past
730 days — same thresholds as weight and BP. A chip in the chart header
shows the active aggregation level.
- AI insights gain hard anti-hallucination guardrails. Provider responses
are validated against a strict Zod schema (summary, recommendations with
mandatory `metricSource`, citations, warnings). Recommendations citing
data points not present in the snapshot are rejected. The wrapper
retries exactly once with a corrective system message; if the second
attempt also fails, it returns 422 instead of unsanitised output.
Codex-backend slug drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now
cushioned by a configurable fallback chain + 1-hour positive cache; if
every slug fails the route returns a structured 503 with the
`attempted[]` list.
- The `/admin/api-tokens` table is responsive on mobile. Narrow viewports
gate columns behind breakpoints (last-used, created-at, owner) and the
owner username falls back inline next to the token name so hiding the
column never drops data.
- Mood tile on mobile shows only the large score number + label, no more
doubled rendering.
- Quick-add submenu disambiguated. The two entries are now called “Messung
erfassen” / “Stimmung erfassen” (DE) and “Log measurement” / “Log mood”
(EN) — previously both simply “Add”.
### Fixed
- **Blutdruck-Zielbereich-Prozent.** Die Zielbereich-Kachel zählte
Sys/Dia-Paare nicht korrekt, sobald durch Importe die Sys/Dia-
Zeitstempel weiter als 5 Minuten auseinander lagen — Ergebnis
konnte 0 % anzeigen, obwohl die meisten Werte im Zielbereich
waren. Fix: Tagesschlüssel-Fallback (gleicher Berliner Tag) +
Paaranzahl als Nenner.
_Blood-pressure target-range percentage. The target-range tile
miscounted sys/dia pairs once import drift pushed sys/dia
timestamps more than 5 minutes apart — the tile could show 0 %
even though most readings were inside the target range. Fix: a
same-Berlin-day key fallback + pair-count as denominator._
- **Onboarding-Flackern.** Auf bereits abgeschlossenen Accounts
rendert die Onboarding-Karte nicht mehr für ~500 ms, bevor sie
wieder verschwindet. Sie wird erst gemountet, sobald der
Analytics-Status geladen ist, und öffnet sich nicht mehr
automatisch, wenn alles erledigt ist.
_Onboarding flicker. On accounts where onboarding is already
complete, the onboarding card no longer renders for ~500 ms
before vanishing. It mounts only after the analytics status has
loaded, and refuses to auto-open when nothing is left to do._
- **Skip-Link blockiert Logo nicht.** Der „Zum Inhalt
springen“-Skip-Link liegt zwar weiterhin am Anfang des Tabbing-
Reihenfolge, blockiert aber nicht mehr Klicks aufs Logo.
_The “Skip to content” skip-link still leads the tab order but
no longer blocks clicks on the logo._
- **Bug-Report-Eintrag richtet sich nach dem Admin-Toggle.** Wenn
der Admin den Bug-Report deaktiviert hat, verschwindet der
Eintrag aus Sidebar, Bottom-Nav, Topbar und der Fehler-Detail-
„Bug melden“-Schaltfläche.
_The “Report a bug” entry follows the admin feature flag. With
bug reporting disabled, the entry vanishes from the sidebar,
bottom-nav, topbar, and the error-detail “Report bug” button._
- **Feedback-Link richtet sich nach dem Admin-Toggle.** Wenn die
Feedback-Sammlung deaktiviert ist, ist auch der UI-Einstieg
weg.
_The Feedback link follows its admin feature flag. With feedback
disabled, the UI entry point disappears too._
- **Notification-Channels deaktivieren sich auf harte Reject-
Fehler.** Antworten Telegram / ntfy / Web Push wiederholt mit
410 oder anderen permanenten Reject-Codes, deaktiviert sich der
Kanal automatisch und schreibt einen Audit-Log-Eintrag. Status
- Re-Enable-Button finden sich in Settings → Benachrichtigungen.
_Notification channels auto-disable on persistent hard rejects.
If Telegram / ntfy / Web Push respond repeatedly with 410 or
other permanent reject codes, the channel disables itself and
writes an audit-log entry. Status + re-enable button live in
Settings → Notifications._
- **Refresh-Token-Fehler markiert Integration als reauth-pflichtig.**
Bricht der Withings- oder moodLog-Refresh-Token-Tausch ab
(typischerweise nach 90 Tagen ohne Re-Auth), wechselt die
Integration in `error_reauth` und der Settings-Eintrag bittet um
Neu-Verbindung — statt jeden Sync-Tick weiter gegen den Provider
zu hämmern.
_Refresh-token failures flip the integration to “needs reauth”.
When the Withings or moodLog refresh-token exchange fails
(typically after 90 days without re-auth), the integration
switches to `error_reauth` and the Settings entry asks for
re-connect — instead of hammering the upstream every sync
tick._
- **Charts blockieren das Wischen nicht mehr.** Auf Touch-Geräten
fingen die Recharts-Wrapper vertikales Wischen ab; das fühlte
sich auf langsamen Geräten wie Hänger an. Fix: `touch-action:
pan-y` auf den Chart-Containern, vertikaler Scroll geht jetzt
durch.
_Mobile chart containers no longer eat vertical scroll. On touch
devices the Recharts wrappers swallowed vertical pans; on slower
devices that felt like a scroll lockup. Fix: `touch-action:
pan-y` on the chart wrappers, vertical scroll passes through
again._
- **`/admin/users` mobil ohne Overflow.** Auf schmalen Viewports
rendert die User-Tabelle als Card-List statt einer
horizontal-scrollenden Tabelle.
_`/admin/users` on mobile renders as a card list instead of a
horizontally-scrolling table._
- **A11y-Verbesserungen.** Chart-Range-Buttons, Medikamenten-
Primary-Buttons, Mood-List-Mobile-Icon-Buttons, Login-CTAs,
Settings → Account-Passkey-Tabelle, Onboarding-Tour-Focus-Trap +
DE-Overflow + Backdrop-Ring — alles auf 44 px Mindest-
Tap-Target bzw. mit `aria-label` / Keyboard-Bedienbarkeit
versehen.
_Accessibility round of fixes: chart range buttons, medication
primary buttons, mood-list mobile icon buttons, login CTAs,
Settings → Account passkey table, onboarding-tour focus trap +
DE-locale overflow + backdrop ring — all aligned to 44 px tap
targets and labelled / keyboard-navigable._
- Blood-pressure target-range percentage. The target-range tile miscounted
sys/dia pairs once import drift pushed sys/dia timestamps more than 5
minutes apart — the tile could show 0 % even though most readings were
inside the target range. Fix: a same-Berlin-day key fallback +
pair-count as denominator.
- Onboarding flicker. On accounts where onboarding is already complete,
the onboarding card no longer renders for ~500 ms before vanishing. It
mounts only after the analytics status has loaded, and refuses to
auto-open when nothing is left to do.
- The “Skip to content” skip-link still leads the tab order but no longer
blocks clicks on the logo.
- The “Report a bug” entry follows the admin feature flag. With bug
reporting disabled, the entry vanishes from the sidebar, bottom-nav,
topbar, and the error-detail “Report bug” button.
- The Feedback link follows its admin feature flag. With feedback
disabled, the UI entry point disappears too.
- Notification channels auto-disable on persistent hard rejects. If
Telegram / ntfy / Web Push respond repeatedly with 410 or other
permanent reject codes, the channel disables itself and writes an
audit-log entry. Status + re-enable button live in Settings →
Notifications.
- reauth` und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden
Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures
flip the integration to “needs reauth”. When the Withings or moodLog
refresh-token exchange fails (typically after 90 days without re-auth),
the integration switches to `error_reauth` and the Settings entry asks
for re-connect — instead of hammering the upstream every sync tick.
- Mobile chart containers no longer eat vertical scroll. On touch devices
the Recharts wrappers swallowed vertical pans; on slower devices that
felt like a scroll lockup. Fix: `touch-action: pan-y` on the chart
wrappers, vertical scroll passes through again.
- `/admin/users` on mobile renders as a card list instead of a
horizontally-scrolling table.
- Accessibility round of fixes: chart range buttons, medication primary
buttons, mood-list mobile icon buttons, login CTAs, Settings → Account
passkey table, onboarding-tour focus trap + DE-locale overflow +
backdrop ring — all aligned to 44 px tap targets and labelled /
keyboard-navigable.
### Security
- **AI-Provider-Antworten gegen striktes Schema validiert.** Der
Insight-Wrapper akzeptiert nur Antworten, die das strikte
`aiInsightResponseSchema` erfüllen, und verweigert Empfehlungen,
die Datenpunkte zitieren, die nicht im übergebenen Snapshot
stehen. Schemafehler triggern einen einmaligen Retry mit
korrigierender Systemnachricht; ein zweiter Fehler liefert 422
statt halluzinierter Ausgaben.
_AI provider responses are validated against a strict Zod
schema. The insight wrapper only accepts responses that satisfy
the strict `aiInsightResponseSchema`, and rejects recommendations
citing data points absent from the supplied snapshot. Schema
failures trigger a single retry with a corrective system message;
a second failure returns 422 instead of hallucinated output._
- **Backup-Operationen vollständig auditiert.** Run, Download,
Upload und Restore — inklusive Start-Marker, Denial-Reasons und
Failures — schreiben jeweils einen Audit-Log-Eintrag mit Akteur
und Snapshot-ID. Restore wird zusätzlich durch fünf unabhängige
Gates abgesichert (Cookie-Auth-only-Admin, `confirm: "RESTORE"`-
Body, getypte UI-Bestätigung, Idempotency-Key, Pre-Tx-Enum-
Validation).
_Backup operations are fully audited. Run, download, upload, and
restore — including start markers, denial reasons, and failures
— each write an audit-log entry with actor and snapshot ID.
Restore is additionally protected by five independent gates
(cookie-only admin auth, `confirm: "RESTORE"` body, typed UI
confirmation, idempotency-key wrap, pre-transaction enum
validation)._
- AI provider responses are validated against a strict Zod schema. The
insight wrapper only accepts responses that satisfy the strict
`aiInsightResponseSchema`, and rejects recommendations citing data
points absent from the supplied snapshot. Schema failures trigger a
single retry with a corrective system message; a second failure returns
422 instead of hallucinated output.
- Backup operations are fully audited. Run, download, upload, and restore
— including start markers, denial reasons, and failures — each write an
audit-log entry with actor and snapshot ID. Restore is additionally
protected by five independent gates (cookie-only admin auth, `confirm:
"RESTORE"` body, typed UI confirmation, idempotency-key wrap,
pre-transaction enum validation).
## [1.4.14] — 2026-05-09
### Added
- **Admin-Bereich nach Sektionen aufgeteilt.** Statt einer monolithischen
`/admin`-Seite mit Anker-Sprüngen hat jede Sektion jetzt ihre eigene
Route: `/admin/system-status`, `/admin/services`,
`/admin/integrations`, `/admin/feedback`, `/admin/reminders`,
`/admin/users`, `/admin/api-tokens`, `/admin/login-overview`,
`/admin/backups`, `/admin/danger-zone`. Sidebar bekommt eine
ausklappbare Admin-Gruppe; Status-Cards verlinken direkt auf die
passende Unterseite. Alte `/admin#section-…`-Pfade werden serverseitig
weitergeleitet.
_The admin area is split into per-section pages instead of one
monolithic dashboard with anchor jumps. Each section has its own URL
- The admin area is split into per-section pages instead of one monolithic
dashboard with anchor jumps. Each section has its own URL
(`/admin/system-status`, `/admin/services`, `/admin/integrations`,
`/admin/feedback`, `/admin/reminders`, `/admin/users`,
`/admin/api-tokens`, `/admin/login-overview`, `/admin/backups`,
`/admin/danger-zone`); the sidebar grows an expandable Admin group;
status cards link to the relevant sub-page; legacy
`/admin#section-…` paths are redirected server-side._
- **Neue Backups-Sektion** unter `/admin/backups` mit Tabelle aller
bisherigen Sicherungen (Größe, Typ, Erstellungszeit) und einem
„Backup jetzt erstellen“-Button, der einen Ad-hoc-Job auf der
pg-boss-Queue auslöst.
_New `/admin/backups` view with a table of all stored backups (size,
status cards link to the relevant sub-page; legacy `/admin#section-…`
paths are redirected server-side.
- New `/admin/backups` view with a table of all stored backups (size,
type, timestamp) and a “Backup now” button that enqueues an ad-hoc
pg-boss job._
- **Neue User-Verwaltung** unter `/admin/users` mit Filter-Pills
(alle / nur Admins / nur Standardnutzer) und einer
„Force-Logout“-Aktion pro Nutzer hinter einem Bestätigungsdialog
(löscht alle aktiven Sessions des Ziels und schreibt einen
Audit-Log-Eintrag). Selbst-Logout ist gesperrt.
_New `/admin/users` view with role filter pills (all / admins only /
pg-boss job.
- New `/admin/users` view with role filter pills (all / admins only /
users only) and a per-row force-logout action behind a confirmation
dialog (deletes every active session of the target and writes an
audit-log entry). Self-target is disabled._
- **„Gespeicherten KI-Schlüssel entfernen“** in Settings → KI: ein
klar betitelter Knopf mit Bestätigungsdialog löscht den
gespeicherten OpenAI- bzw. lokalen Provider-Key, ohne Settings
ansonsten anzufassen.
_New “Remove saved AI key” button in Settings → AI: a clearly
labelled button behind a confirmation dialog clears the stored
OpenAI or local provider key without touching anything else._
- **`CODEX_MODEL`-Env-Var** als Operator-Override für das Codex-
Modell-Slug, damit alternative Modelle ohne Rebuild getestet werden
können (z. B. wenn dein ChatGPT-Plan ein anderes Default-Modell
bevorzugt).
_New `CODEX_MODEL` env var lets operators override the Codex model
slug without a rebuild — useful for testing alternate slugs against
different ChatGPT plan tiers._
audit-log entry). Self-target is disabled.
- New “Remove saved AI key” button in Settings → AI: a clearly labelled
button behind a confirmation dialog clears the stored OpenAI or local
provider key without touching anything else.
- MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit
alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein
ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL`
env var lets operators override the Codex model slug without a rebuild —
useful for testing alternate slugs against different ChatGPT plan tiers.
### Changed
- **Trend-Pfeil-Farben sind jetzt metrik-bewusst.** Steigende Tendenz
bedeutet je nach Messwert etwas anderes: Blutdruck und Gewicht ↑ =
orange (Warnung), Stimmung ↑ = grün (positiv), Puls ↑ = neutral
(kontextabhängig). Die Pfeil-Richtung selbst bleibt unverändert.
_Trend-arrow colors are metric-aware: BP and weight rising = orange
- Trend-arrow colors are metric-aware: BP and weight rising = orange
(warning), mood rising = green (positive), pulse rising = neutral
(context-dependent). Arrow direction itself is unchanged._
- **Wipe-all-data deckt jetzt auch Notification-Channels und Web-Push-
Subscriptions ab.** Verschlüsselte Telegram-Bot-Tokens und
Web-Push-Endpoints überleben einen vollständigen Account-Wipe nicht
länger. Feedback und Audit-Log bleiben erhalten (wie bisher).
Bestätigungstext im UI nennt den neuen Umfang explizit.
_“Wipe all data” now also clears notification channels and web-push
subscriptions: encrypted Telegram bot tokens and web-push endpoints
no longer survive a full account wipe. Feedback and audit log stay
untouched (unchanged). The confirmation copy spells out the new
scope._
- **i18n-Schlüssel im Admin-Bereich** unter `admin.section.<slug>.*`
reorganisiert (EN + DE-Parität).
_Admin-area i18n keys reorganised under `admin.section.<slug>.*`
(EN + DE parity)._
(context-dependent). Arrow direction itself is unchanged.
- “Wipe all data” now also clears notification channels and web-push
subscriptions: encrypted Telegram bot tokens and web-push endpoints no
longer survive a full account wipe. Feedback and audit log stay
untouched (unchanged). The confirmation copy spells out the new scope.
- Admin-area i18n keys reorganised under `admin.section.<slug>.*` (EN + DE
parity).
### Fixed
- **Codex/ChatGPT-Modell-Slug korrigiert.** Der Codex-Backend-Endpoint
lehnt sowohl `gpt-5-codex` als auch `gpt-5` für ChatGPT-Account-
Auth ab; das tatsächliche, codex-optimierte Slug für die Plus/Pro-
Tarife heißt `gpt-5.3-codex`. v1.4.14 verdrahtet den richtigen
Default. „Verbindung testen“ in Settings → KI und der Insight-
Generator laufen damit beide gegen dein ChatGPT-Abo durch.
_Codex/ChatGPT model slug corrected: the Codex backend rejects both
`gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account;
the codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`.
v1.4.14 wires the correct default. Settings → AI “Test connection”
and the insight generator now both succeed against your ChatGPT
subscription._
- Codex/ChatGPT model slug corrected: the Codex backend rejects both
`gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account; the
codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`. v1.4.14
wires the correct default. Settings → AI “Test connection” and the
insight generator now both succeed against your ChatGPT subscription.
- **Sommerzeit-Wechsel verschiebt keine Tageswerte mehr.** Die
Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in
reinem UTC und verlor an den Berliner DST-Grenzen einen Tag. Die
@@ -951,88 +942,49 @@ pan-y` on the chart wrappers, vertical scroll passes through
helper anchors at Berlin Y-M-D and subtracts in 86_400_000-ms
steps; bucket pairing across blood pressure, weight, and mood is
now DST-safe.*
- **KI-Provider-Fehler werden korrekt klassifiziert.**
`/api/insights/generate` antwortet auf 401/403-Fehler des Providers
jetzt mit 422 („KI-Anbieter hat den Request abgelehnt — bitte
API-Key in Settings → KI prüfen“), auf 5xx mit 503 („KI-Anbieter
vorübergehend nicht erreichbar“) und auf 429 mit 429. Vorher kam
pauschal 500. Volle Fehlerdetails landen weiter im Wide-Event-Log.
_AI provider errors are now correctly classified by
`/api/insights/generate`: provider 401/403 → 422 (“AI provider
rejected the request — check your API key in Settings → AI”),
provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → 429. Previously everything returned 500. Full error context still
lands in the structured logs._
- **Logging-Redactor schluckt keine harmlosen Wörter mehr.** Der
Secret-Redactor maskierte zuvor jedes Vorkommen von `sk-…` — auch
in `task-force`, `risk-management`, `disk-io`. Die neue Regex
verlangt eine Wortgrenze und mindestens 8 Folgezeichen, das Padding
bleibt für echte API-Keys (`sk-…`, `sk-ant-…`) intakt.
_The structured-logging secret redactor no longer mangles innocent
words. The previous regex matched any `sk-…` substring and masked
fragments inside `task-force`, `risk-management`, `disk-io`. The
new pattern requires a word boundary and at least 8 trailing
characters, while still scrubbing real API keys (`sk-…`,
`sk-ant-…`)._
- AI provider errors are now correctly classified by
`/api/insights/generate`: provider 401/403 → 422 (“AI provider rejected
the request — check your API key in Settings → AI”), provider 5xx → 503
(“AI provider temporarily unavailable”), 429 → 429. Previously
everything returned 500. Full error context still lands in the
structured logs.
- The structured-logging secret redactor no longer mangles innocent words.
The previous regex matched any `sk-…` substring and masked fragments
inside `task-force`, `risk-management`, `disk-io`. The new pattern
requires a word boundary and at least 8 trailing characters, while still
scrubbing real API keys (`sk-…`, `sk-ant-…`).
### Performance
- **Insights-Seite startet schneller.** Die Recharts-Symbole für die
Korrelations-Scatter-Plots werden jetzt bedarfsweise per
`next/dynamic` geladen statt eager mitausgeliefert; das spart
ca. 108 KiB initiales JavaScript auf der Insights-Seite. Die
Chart-Optik bleibt unverändert.
_Bundle-size improvements on the insights page: Recharts symbols
for the correlation scatter plots are now loaded on demand via
`next/dynamic` instead of being shipped eagerly, saving roughly
108 KiB of initial JavaScript on `/insights`. Chart visuals are
unchanged._
- **Dashboard fragt die Onboarding-Checkliste nicht mehr ab, wenn
sie schon abgeschlossen ist.** Sobald `onboardingCompletedAt`
gesetzt ist, entfallen die `withings/status`- und
`notifications/preferences`-Calls beim Dashboard-Aufruf. Spart
rund 950 ms Netzwerkzeit bei wiederkehrenden Nutzern.
_The dashboard skips the onboarding-checklist API requests once
- Bundle-size improvements on the insights page: Recharts symbols for the
correlation scatter plots are now loaded on demand via `next/dynamic`
instead of being shipped eagerly, saving roughly 108 KiB of initial
JavaScript on `/insights`. Chart visuals are unchanged.
- The dashboard skips the onboarding-checklist API requests once
onboarding is complete. With `onboardingCompletedAt` set, the
`withings/status` and `notifications/preferences` queries no longer
fire on dashboard load — saves about 950 ms of network time for
established users._
`withings/status` and `notifications/preferences` queries no longer fire
on dashboard load — saves about 950 ms of network time for established
users.
### Tests & A11y
- **Erweiterte End-to-End-Suite.** Neue Playwright-Specs decken den
authentifizierten Dashboard-Render, den „Messung anlegen“-Flow,
den Doctor-Report-PDF-Download, einen gemockten Codex-Connect-
Flow, einen gemockten Insights-Generate-Flow und einen
Mobile-Viewport-Smoketest (Pixel 5) ab.
_Extended end-to-end suite. New Playwright specs cover the
authenticated dashboard render, the “add measurement” flow, the
doctor-report PDF download, a mocked Codex connect flow, a mocked
insights-generate flow, and a mobile-viewport smoke test (Pixel 5)._
- **Axe-core-Audit auf allen Schlüsselrouten grün.** Die ernsten
und kritischen Verstöße auf `/dashboard`, `/settings/integrations`
und `/admin` (inkl. `/admin/system-status` und `/admin/users`)
wurden behoben — fehlende `aria-label`s an Icon-Buttons, fehlende
Namen am Mobile-User-Menü-Trigger, leere `<dd>`-Platzhalter,
Login-Link nur durch Farbe erkennbar, Quick-Add-Menü-Items mit
doppeltem Namen.
_Axe-core accessibility audit now clean of serious/critical
violations on `/dashboard`, `/settings/integrations`, and `/admin`
(incl. `/admin/system-status`, `/admin/users`). Fixes: missing
aria-labels on icon-only buttons, unnamed mobile user-menu trigger,
empty `<dd>` placeholders, sign-up link distinguishable only by
color, quick-add menu with two indistinguishable items._
- Extended end-to-end suite. New Playwright specs cover the authenticated
dashboard render, the “add measurement” flow, the doctor-report PDF
download, a mocked Codex connect flow, a mocked insights-generate flow,
and a mobile-viewport smoke test (Pixel 5).
- Axe-core accessibility audit now clean of serious/critical violations on
`/dashboard`, `/settings/integrations`, and `/admin` (incl.
`/admin/system-status`, `/admin/users`). Fixes: missing aria-labels on
icon-only buttons, unnamed mobile user-menu trigger, empty `<dd>`
placeholders, sign-up link distinguishable only by color, quick-add menu
with two indistinguishable items.
### Docs
- **Dokumentations-Site auf v1.4.14 aktualisiert** — Codex-Device-
Code-Pfad neu beschrieben, neue Admin-Sektionen (Backups, Users)
dokumentiert, `CODEX_MODEL`-Env-Var aufgenommen,
Dashboard-Performance-Hinweis ergänzt, Codex-Troubleshooting-Block
überarbeitet.
_Documentation site brought up to date with v1.4.14 — Codex
device-code flow rewritten, new Admin sections (Backups, Users)
documented, `CODEX_MODEL` env var added, dashboard performance note
added, Codex troubleshooting block revised._
- Documentation site brought up to date with v1.4.14 — Codex device-code
flow rewritten, new Admin sections (Backups, Users) documented,
`CODEX_MODEL` env var added, dashboard performance note added, Codex
troubleshooting block revised.
## [1.4.13] — 2026-05-09
View File
File renamed without changes.
+1 -1
View File
@@ -78,7 +78,7 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
- **shadcn/ui** components (new-york style) for UI primitives.
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
See `CLAUDE.md` for detailed architecture and patterns.
See `CONTRIBUTING-AI.md` for detailed architecture and patterns.
## Database Changes
+23 -1
View File
@@ -167,9 +167,31 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
// Walk every element inside the mobile card-list and assert no
// child has a wider scrollWidth than its container. This is the
// Pixel-5 worst-case Marc kept hitting.
//
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
// design `scrollWidth > clientWidth` whenever the underlying text
// is longer than the parent column — that IS the truncation, not
// an overflow leak. Skip those so the assertion only fires on real
// layout culprits (a span without a width clamp pushing its parent
// wider). The page-level `dims.scrollWidth <= dims.innerWidth + 1`
// assertion below remains the authoritative no-scrollbar guard.
const culprits = await page
.getByTestId("admin-tokens-mobile-list")
.evaluate((root) => {
const isTruncated = (el: Element): boolean => {
const cs = window.getComputedStyle(el);
if (cs.textOverflow === "ellipsis" && cs.overflowX !== "visible") {
return true;
}
// Tailwind's `truncate` shorthand and any explicit overflow
// clamp also legitimately keep the visible content inside
// clientWidth even though scrollWidth measures full content.
if (cs.overflowX === "hidden" || cs.overflowX === "clip") {
return true;
}
return false;
};
const out: Array<{
tag: string;
klass: string;
@@ -177,7 +199,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
clientWidth: number;
}> = [];
for (const el of root.querySelectorAll("*")) {
if (el.scrollWidth > el.clientWidth + 1) {
if (el.scrollWidth > el.clientWidth + 1 && !isTruncated(el)) {
out.push({
tag: el.tagName,
klass:
+17 -6
View File
@@ -103,17 +103,28 @@ test.describe("chart overlay controls", () => {
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
// Scroll the trigger into view before opening the dropdown so the
// Radix Popper places its portalled content inside the viewport.
// On the seeded test user the dashboard renders multiple stacked
// chart cards, and the first cog can sit near the bottom edge of
// the desktop 1280×720 viewport — opening the popover then drops
// the toggle below the fold and Playwright refuses to click an
// off-viewport element after auto-scrolling.
await trigger.scrollIntoViewIfNeeded();
await trigger.click();
// The popover content paints three switches.
await expect(
page.locator('[data-slot="chart-overlay-toggle-target-range"]'),
).toBeVisible({ timeout: 5_000 });
const targetRangeToggle = page.locator(
'[data-slot="chart-overlay-toggle-target-range"]',
);
await expect(targetRangeToggle).toBeVisible({ timeout: 5_000 });
// Toggle target-range on. Radix Switch is a button[role=switch].
await page
.locator('[data-slot="chart-overlay-toggle-target-range"]')
.click();
// Force the click — `scrollIntoViewIfNeeded()` on the portalled
// dropdown content is a no-op (the Radix portal sits at the body
// root, outside the page scroll container) so we bypass Playwright's
// viewport check directly.
await targetRangeToggle.click({ force: true });
// The PUT fires once for the toggle change.
await expect.poll(() => putRequestCount).toBeGreaterThan(0);
+18 -2
View File
@@ -198,8 +198,24 @@ test.describe("charts — mobile-viewport regression", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Wait for at least one Recharts SVG to mount.
await page.locator(".recharts-xAxis").first().waitFor({ timeout: 10_000 });
// Wait for at least one chart card to mount, then scroll it into
// view so Recharts' ResponsiveContainer measures its parent and
// paints the SVG. On Pixel 5 the dashboard ships several stacked
// tiles above the first chart card; the bottom-positioned axis
// sits below the initial fold and reports as `hidden` to a raw
// `.recharts-xAxis` visibility probe before scroll.
const firstChartCard = page
.locator("div.bg-card")
.filter({ has: page.locator("[data-slot=chart-range-tab]") })
.first();
await firstChartCard.waitFor({ state: "visible", timeout: 10_000 });
await firstChartCard.scrollIntoViewIfNeeded();
// After scroll, give the chart a beat to lay out under the new
// measured size before counting ticks.
await page
.locator(".recharts-xAxis .recharts-cartesian-axis-tick text")
.first()
.waitFor({ state: "visible", timeout: 10_000 });
// Recharts lays its visible tick labels under
// `.recharts-xAxis .recharts-cartesian-axis-tick`. Count the ones
+9 -5
View File
@@ -215,11 +215,15 @@ test.describe("insights advisor card on /insights (C1)", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Hero + advisor card both render. The hero is from B1b; the
// advisor card is the C1 deliverable.
await expect(page.locator('[data-slot="insights-page-hero"]')).toBeVisible({
timeout: 10_000,
});
// Hero + advisor card both render. v1.4.20 B1 replaced the legacy
// `<InsightsPageHero>` (data-slot="insights-page-hero") with the
// condensed `<HeroStrip>` (data-slot="insights-hero-strip"); the
// advisor card is the C1 deliverable below it.
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible(
{
timeout: 10_000,
},
);
// The recommendation card surface from B5c — `data-slot` markers
// are pinned in tests, look for the rec text the mock provided.
+15 -4
View File
@@ -133,10 +133,21 @@ test.describe("dashboard onboarding card flicker guard", () => {
test("incomplete-onboarding user sees the card collapsed by default", async ({
page,
}) => {
// Onboarding is incomplete AND profile fields are missing → the
// `shouldShowChecklist` gate is true. With the fix, the card mounts
// collapsed: header + progress meter visible, full row list hidden
// until the user clicks the chevron.
// Profile fields are missing AND `measurementCount < 5` so the
// `shouldShowChecklist` gate is true (its `stillInSetup` branch is
// satisfied by the measurement-count condition). With the fix, the
// card mounts collapsed: header + progress meter visible, full row
// list hidden until the user clicks the chevron.
//
// v1.4.22 C4 — the onboarding redirect now lives in `proxy.ts`,
// not in `<AuthShell>`. The proxy reads the `hl_onboarding` cookie
// (which the auth routes mirror from the DB). Our storageState
// belongs to a fully-onboarded user (no `hl_onboarding` cookie),
// so the proxy passes the dashboard through even when the mocked
// `/api/auth/me` reports `onboardingCompletedAt: null`. That's the
// exact case this test wants to cover — the checklist mounts
// because `measurementCount < 5` AND `onboardingCompletedAt` is
// null, but the page itself still paints.
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
+5 -3
View File
@@ -176,10 +176,12 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
}
const state = await ctx.storageState();
// Strip any cookies we don't recognise so storageState only carries
// what the auth shell needs. (Mostly defensive — the proxy currently
// only sets `healthlog_session` and `healthlog_locale`.)
// what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the
// login route sets it to "pending" for new users and DELETES it for
// already-onboarded ones, so for our pre-seeded completed e2e user
// the cookie is absent and the proxy passes the dashboard through.
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "healthlog_locale"].includes(c.name),
["healthlog_session", "healthlog_locale", "hl_onboarding"].includes(c.name),
);
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
await ctx.dispose();
+25 -19
View File
@@ -216,7 +216,7 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"greetingSalutation": "Hi, {name}",
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
@@ -262,7 +262,7 @@
},
"measurements": {
"title": "Messungen",
"subtitle": "Gewicht, Blutdruck, Puls & mehr",
"subtitle": "Gewicht, Blutdruck, Puls und mehr",
"addMeasurement": "Hinzufügen",
"editMeasurement": "Messwert bearbeiten",
"type": "Typ",
@@ -327,7 +327,7 @@
},
"mood": {
"title": "Stimmung",
"subtitle": "Stimmungsverlauf & Einträge",
"subtitle": "Stimmungsverlauf und Einträge",
"addEntry": "Hinzufügen",
"editEntry": "Eintrag bearbeiten",
"moodLevel": "Stimmung",
@@ -594,6 +594,7 @@
"movingAverage7d": "7-Tage-Trend",
"avg7dShort": "7T",
"avg30dShort": "30T",
"avgAllTimeShort": "Allzeit",
"trend7dShort": "7-Tage-Trend",
"trend": "Trend",
"targetRanges": "Zielbereiche",
@@ -743,7 +744,7 @@
"confidenceMittel": "Mittel",
"confidenceGering": "Gering",
"loadError": "Auswertungen konnten nicht geladen werden",
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte erneut versuchen.",
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte versuche es erneut.",
"title": "Insights",
"subtitle": "KI-basierte Analyse deiner Gesundheitsdaten",
"overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick",
@@ -773,10 +774,10 @@
"progressAria": "Gesundheitsscore-Fortschritt",
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
"componentBp": "BD",
"componentBp": "Blutdruck",
"componentWeight": "Gewicht",
"componentMood": "Mood",
"componentCompliance": "Medis",
"componentMood": "Stimmung",
"componentCompliance": "Einnahmetreue",
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"askCoach": "Coach fragen",
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
@@ -825,12 +826,12 @@
"confidenceLow": "Geringe Konfidenz"
},
"correlationRow": {
"title": "Muster",
"title": "Zusammenhänge",
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
"disclaimer": "Muster sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
"disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
"experimentCta": "7-Tage-Experiment starten",
"experimentTooltip": "Bald verfügbar",
"emptyTitle": "Mehr Daten nötig, um dieses Muster zu zeigen",
"emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen",
"sourceChip": "basierend auf {n} gepaarten Messungen · {window}",
"sourceWindowLast30": "letzte 30 Tage",
"confidenceHigh": "Hohe Konfidenz",
@@ -852,8 +853,6 @@
"errorProvider": "Der Coach konnte gerade keinen KI-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"settings": "Coach-Einstellungen",
"settingsTooltip": "Coach-Einstellungen kommen mit v1.4.21.",
"send": "Senden",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
@@ -870,6 +869,7 @@
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
"evidenceLabel": "Worauf bezieht sich das?",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
@@ -899,6 +899,7 @@
"navMood": "Stimmung",
"navMedication": "Medikamente",
"navBmi": "BMI",
"navAriaLabel": "Zu Abschnitt springen",
"measurementsOverDays": "{count} Messungen über {days} Tage",
"healthStatus": "Gesundheitsstatus",
"noWeightData": "Keine Gewichtsdaten oder Größe nicht hinterlegt.",
@@ -1069,10 +1070,10 @@
"targetSys": "Systolisch (mmHg)",
"targetDia": "Diastolisch (mmHg)",
"targetWeight": "Zielgewicht (kg)",
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte versuche es erneut.",
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte versuche es erneut oder lege es später unter „Medikamente\" an.",
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte versuche es erneut.",
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte versuche es erneut.",
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
"v2": {
"title": "Willkommen bei HealthLog",
@@ -1304,7 +1305,7 @@
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
"passkeyAlreadyRegistered": "Dieses Gerät ist bereits als Passkey für dein Konto registriert.",
"passkeySecurityBlocked": "Dein Browser hat die Passkey-Anfrage aus Sicherheitsgründen blockiert. Bitte auf einer vertrauenswürdigen HTTPS-URL erneut versuchen.",
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte erneut versuchen.",
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte versuche es erneut.",
"passkeyUnknownError": "Passkey-Registrierung fehlgeschlagen: {message}",
"deletePasskey": "Passkey löschen?",
"deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.",
@@ -1808,6 +1809,7 @@
"bugReportNotConfigured": "Bugreport ist noch nicht vollständig konfiguriert.",
"bugReportSave": "Bugreport speichern",
"userManagement": "Benutzerverwaltung",
"userName": "Name",
"userEmail": "E-Mail",
"userRole": "Rolle",
"userPasskeys": "Passkeys",
@@ -1883,7 +1885,7 @@
"reminderMissedMinutesDescription": "Minuten nach Fenster-Ende bis die Einnahme als verpasst gilt.",
"reminderThresholdsSave": "Schwellenwerte speichern",
"notificationTest": "Benachrichtigungen testen",
"notificationTestSend": "Test-Notification senden",
"notificationTestSend": "Test senden",
"notificationTestSuccess": "Test-Notification wurde gesendet",
"notificationTestFailed": "Test-Notification konnte nicht gesendet werden",
"reminderCheckRun": "Reminder-Check ausführen",
@@ -2043,7 +2045,7 @@
"app-logs": {
"title": "App-Logs",
"subtitle": "Letzte strukturierte Wide-Events dieses Prozesses anzeigen.",
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern LOKI_ENDPOINT konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern die Integration konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
"filterTraceId": "Trace-ID",
"filterAction": "Aktion enthält…",
"filterLevel": "Level",
@@ -2431,6 +2433,7 @@
},
"notifications": {
"title": "Benachrichtigungen",
"eventColumn": "Ereignis",
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
"goToSettings": "Zu den Einstellungen",
@@ -2556,6 +2559,9 @@
"moodStability": "Stimmungsstabilität",
"steps": "Schritte",
"average30d": "30-Tage-Schnitt:",
"deltaVsLastMonth": "{delta} {unit} vs. Vormonat",
"deltaVsLastMonthFlat": "± 0 {unit} vs. Vormonat",
"deltaUnavailable": "Zu wenige Werte für einen Vormonats-Vergleich",
"currentValue": "Aktuell: {value} {unit}",
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
"sourceLabel": "Quelle: {source}",
+15 -9
View File
@@ -262,7 +262,7 @@
},
"measurements": {
"title": "Measurements",
"subtitle": "Weight, blood pressure, pulse & more",
"subtitle": "Weight, blood pressure, pulse and more",
"addMeasurement": "Add",
"editMeasurement": "Edit measurement",
"type": "Type",
@@ -327,7 +327,7 @@
},
"mood": {
"title": "Mood",
"subtitle": "Mood tracker & entries",
"subtitle": "Mood tracker and entries",
"addEntry": "Add",
"editEntry": "Edit entry",
"moodLevel": "Mood",
@@ -594,6 +594,7 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Trend",
"targetRanges": "Target ranges",
@@ -825,12 +826,12 @@
"confidenceLow": "Low confidence"
},
"correlationRow": {
"title": "Patterns",
"title": "Relationships",
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
"disclaimer": "Patterns are observational, not causal — talk to your doctor if BP runs high.",
"disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
"experimentCta": "Try a 7-day experiment",
"experimentTooltip": "Coming soon",
"emptyTitle": "Need more data to see this pattern",
"emptyTitle": "Need more data to see this relationship",
"sourceChip": "based on {n} paired readings · {window}",
"sourceWindowLast30": "last 30 days",
"confidenceHigh": "High confidence",
@@ -852,8 +853,6 @@
"errorProvider": "The Coach could not reach an AI provider just now. Please try again in a moment.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"settings": "Coach settings",
"settingsTooltip": "Coach settings arrive in v1.4.21.",
"send": "Send",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
@@ -870,6 +869,7 @@
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
"evidenceLabel": "What I'm looking at",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
@@ -899,6 +899,7 @@
"navMood": "Mood",
"navMedication": "Medication",
"navBmi": "BMI",
"navAriaLabel": "Skip to section",
"measurementsOverDays": "{count} measurements over {days} days",
"healthStatus": "Health Status",
"noWeightData": "No weight data or height not set.",
@@ -1808,6 +1809,7 @@
"bugReportNotConfigured": "Bug report is not fully configured yet.",
"bugReportSave": "Save bug report settings",
"userManagement": "User Management",
"userName": "Name",
"userEmail": "Email",
"userRole": "Role",
"userPasskeys": "Passkeys",
@@ -1883,7 +1885,7 @@
"reminderMissedMinutesDescription": "Minutes after window end until the intake is considered missed.",
"reminderThresholdsSave": "Save thresholds",
"notificationTest": "Test Notifications",
"notificationTestSend": "Send test notification",
"notificationTestSend": "Send test",
"notificationTestSuccess": "Test notification sent",
"notificationTestFailed": "Failed to send test notification",
"reminderCheckRun": "Run reminder check",
@@ -2043,7 +2045,7 @@
"app-logs": {
"title": "App Logs",
"subtitle": "Tail the most recent structured wide-events emitted by this process.",
"processNote": "Showing app-process logs only — worker-process logs go to Loki when LOKI_ENDPOINT is configured. Buffer is volatile and capped at 500 entries.",
"processNote": "Showing app-process logs only — worker-process logs go to Loki when that integration is configured. Buffer is volatile and capped at 500 entries.",
"filterTraceId": "Trace ID",
"filterAction": "Action contains…",
"filterLevel": "Level",
@@ -2431,6 +2433,7 @@
},
"notifications": {
"title": "Notifications",
"eventColumn": "Event",
"subtitle": "Choose which notifications you want to receive through which channel.",
"noChannels": "Configure a notification channel in Settings first.",
"goToSettings": "Go to Settings",
@@ -2556,6 +2559,9 @@
"moodStability": "Mood stability",
"steps": "steps",
"average30d": "30-day average:",
"deltaVsLastMonth": "{delta} {unit} vs. last month",
"deltaVsLastMonthFlat": "± 0 {unit} vs. last month",
"deltaUnavailable": "Not enough data for a vs.-last-month comparison",
"currentValue": "Current: {value} {unit}",
"targetRangeValue": "Target: {min}–{max} {unit}",
"sourceLabel": "Source: {source}",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.21",
"version": "1.4.22",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.4.22 C4 — onboarding redirect moved from `<AuthShell>` useEffect
* to `src/proxy.ts` so it lands on the first server response instead
* of post-hydration. The previous client-side approach caused a brief
* dashboard flash for users with `onboardingCompletedAt === null`.
*
* The proxy reads the `hl_onboarding` cookie (which the auth routes
* mirror from the DB `onboardingCompletedAt` column) without a DB
* roundtrip — Edge runtime can't reach Prisma. 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.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function makeRequest(
pathname: string,
cookies: Record<string, string> = {},
): NextRequest {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
it("307s a session with hl_onboarding=pending from / to /onboarding", () => {
const res = proxy(
makeRequest("/", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
// NextResponse.redirect returns a 307 by default in Next 16.
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
});
it("307s a pending session from /insights to /onboarding", () => {
const res = proxy(
makeRequest("/insights", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
});
it("does NOT redirect from /onboarding itself", () => {
const res = proxy(
makeRequest("/onboarding", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
// /onboarding is in PUBLIC_PATHS so the proxy passes through; the
// response is a NextResponse.next() with security headers applied.
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("does NOT redirect from /auth/login", () => {
const res = proxy(makeRequest("/auth/login", { hl_onboarding: "pending" }));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("passes through API routes even when onboarding is pending", () => {
// API routes do their own getSession() checks — the proxy must
// never redirect them or every legitimate /api/* call would 307.
const res = proxy(
makeRequest("/api/measurements", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("still redirects unauthenticated users to /auth/login (existing contract)", () => {
const res = proxy(makeRequest("/"));
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
});
/**
* v1.4.22 W5 reconcile (Sec-MED-2) — exact-match `/onboarding`
* plus subroutes. A hypothetical `/onboarding-export` route must
* NOT inherit the public-pass-through that the literal
* `/onboarding` carries.
*/
it("does NOT treat /onboarding-export as the onboarding surface (exact-match guard)", () => {
const res = proxy(makeRequest("/onboarding-export"));
// Unauthenticated request to a non-public route: redirects to
// /auth/login. If the loose `startsWith("/onboarding")` matcher
// were still in place it would short-circuit through public-path
// handling and 200 instead.
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
});
it("still treats subroutes /onboarding/<x> as the onboarding surface", () => {
const res = proxy(
makeRequest("/onboarding/step-2", {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
// /onboarding/step-2 is part of the onboarding surface so the
// pending redirect short-circuits.
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
});
+65 -3
View File
@@ -8,7 +8,7 @@ import { join } from "node:path";
*
* 1. The dashboard page does NOT mount `<CompareToggle />`. The
* comparison-baseline switch belongs on `/insights` only — the
* dashboard tile-strip is too tight for it (Marc 2026-05-10).
* dashboard tile-strip is too tight for it (maintainer probe 2026-05-10).
* The underlying `compareBaseline` value is still consumed by
* every dashboard chart; only the on-surface affordance is gone.
* 2. The `/insights` page renders exactly ONE page-level refresh
@@ -22,7 +22,7 @@ import { join } from "node:path";
* 4. The `/insights` page does NOT pass the `aiOverviewTitle`
* ("Persönlicher Berater") subtitle into `<InsightAdvisorCard>`.
* The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing;
* the second subtitle was an empty-section placeholder Marc
* the second subtitle was an empty-section placeholder the maintainer
* called "Titel da aber NICHTS PASSIERT".
* 5. The `stripChartTokens()` matcher catches lowercase + mixed-
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`),
@@ -150,7 +150,7 @@ describe("v1.4.19 A3 — /insights polish", () => {
});
describe("v1.4.19 A3 — chart-token leak hardening", () => {
// The visible "metric: blood_pressure_sweet" leak Marc saw at the
// The visible "metric: blood_pressure_sweet" leak the maintainer saw at the
// bottom of /insights was a model-emitted lowercase chart token
// that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch
// because the character class only matches uppercase. The widened
@@ -180,3 +180,65 @@ describe("v1.4.19 A3 — chart-token leak hardening", () => {
expect(parseChartTokens("metric:WEIGHT")).toEqual(["metric:WEIGHT"]);
});
});
describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta uses period-aligned baseline", () => {
/**
* Up to v1.4.22 W4 the BD tile computed `bp30 - bpAll`
* (last-30-days minus all-time) regardless of which window the user
* picked under Settings → Dashboard. The caption still rendered "vs.
* last month" / "vs. last year" via `comparison.captionLastMonth` /
* `comparison.captionLastYear`, so the user read a sentence whose
* numerator was actually a 30d-vs-all-time delta. Every other tile
* routes through `tileCompareDelta()` with `summary.avg30LastMonth`
* / `summary.avg30LastYear` for honest period-aligned math.
*
* This test pins that the dashboard now subtracts the prior-period
* window (priorMonth / priorYear) instead of all-time.
*/
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
const src = load(DASHBOARD_PATH);
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
expect(src).toContain("bpInTargetPctPriorMonth");
expect(src).toContain("bpInTargetPctPriorYear");
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
// for the comparison delta any more.
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
});
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
const ROUTE_SRC = readFileSync(
join(ROOT, "src/app/api/analytics/route.ts"),
"utf8",
);
expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth");
expect(ROUTE_SRC).toContain("bpInTargetPctPriorYear");
});
});
describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
/**
* The comparison-overlay toggle is a global preference. Up to v1.4.21
* the toggle existed both in `/settings/dashboard` (canonical) and
* `/insights` (via `<DailyBriefing metaSlot={<CompareToggle />} />`).
* Per `feedback_settings_no_split.md` the toggle now lives in
* Settings only — the on-surface affordance is gone from `/insights`.
* Every chart still consumes the resolved `comparisonBaseline` value
* the same way it did before, so flipping the Settings toggle still
* propagates to the page on next refetch.
*/
it("/insights does NOT mount <CompareToggle /> anywhere", () => {
const src = load(INSIGHTS_PATH);
expect(src).not.toMatch(/<CompareToggle\b/);
expect(src).not.toMatch(
/from\s+["']@\/components\/comparison\/compare-toggle["']/,
);
});
it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => {
// Drift guard — the page must still hand `compareBaseline` to every
// chart so the global Settings toggle keeps driving the overlay.
const src = load(INSIGHTS_PATH);
expect(src).toContain("comparisonBaseline");
expect(src).toContain("compareBaseline");
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 phase A7 — Marc reported the `/targets` (Zielwerte) page
* v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page
* still surfaces English status labels even when the rest of the UI
* renders in German: "Low / On Target / Stable / Moderate" stayed in
* the source language. The card titles ("Weight", "Blood pressure",
@@ -131,7 +131,7 @@ describe("/targets page — i18n status labels and titles", () => {
it("translates every status badge category to German", () => {
const html = render("de");
// Marc explicitly named these in his bug report.
// the maintainer explicitly named these in his bug report.
expect(html).not.toMatch(/>Low</);
expect(html).not.toMatch(/>On target</);
expect(html).not.toMatch(/>Stable</);
+1 -1
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between
* the page header (h1 + intro) and the first card grid. At Pixel-5
* Marc reported "relativ viel Platz" wasted between the overview
* the maintainer reported "relativ viel Platz" wasted between the overview
* and the first values. Tighten the rhythm to `space-y-6` (24px)
* which matches the rest of the admin / settings pages.
*/
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.22 C1 — `/targets` (Zielwerte) page upgrade.
*
* Each target card grew a 30-day sparkline + a "Δ vs. last month"
* caption so the page reads as a living health log instead of a
* static reference card. The API ships `points30d: number[]` and
* `deltaVsLastMonth: number` per target; the page renders a tiny
* dependency-free SVG path plus the localised delta caption.
*
* Cards without enough data (fewer than 3 points in either window)
* keep their previous v1.4.19 layout — sparkline and delta caption
* stay absent so cold-start accounts don't see a misleading flat
* trace or a "± 0" comparison that's really "no comparison".
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 88.2,
average30: 88.6,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
points30d: [89.0, 88.8, 88.6, 88.4, 88.2],
deltaVsLastMonth: -2.3,
},
{
type: "PULSE",
label: "Resting pulse",
current: 72,
average30: 72,
trend: "stable",
unit: "bpm",
range: { min: 60, max: 100 },
classification: { category: "On target", color: "#50fa7b" },
source: "AHA",
// No sparkline yet — cold-start account with <3 readings.
points30d: null,
deltaVsLastMonth: null,
},
],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "tester",
email: "tester@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — sparkline + delta", () => {
it("renders a sparkline SVG for cards with points30d", () => {
const html = render();
// The sparkline carries a stable data-slot the visual test pins.
expect(html).toContain('data-slot="target-sparkline"');
// Path with the dracula-purple token, not a hardcoded hex.
expect(html).toContain("var(--dracula-purple)");
});
it("renders the Δ-vs-last-month caption with the signed delta", () => {
const html = render();
expect(html).toContain('data-slot="target-delta"');
// Weight: −2.3 kg vs. last month
expect(html).toMatch(/−2\.3\s*kg/);
expect(html).toContain("vs. last month");
});
it("uses German vs.-Vormonat caption when the locale is `de`", () => {
const html = render("de");
expect(html).toContain("vs. Vormonat");
});
it("skips both sparkline and delta when the target has no points", () => {
const html = render();
// PULSE card has no points30d / no deltaVsLastMonth — only ONE
// `target-sparkline` slot should be present (from the WEIGHT card).
const sparklineMatches = html.match(/data-slot="target-sparkline"/g) ?? [];
expect(sparklineMatches.length).toBe(1);
const deltaMatches = html.match(/data-slot="target-delta"/g) ?? [];
expect(deltaMatches.length).toBe(1);
});
});
+2 -2
View File
@@ -97,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client
// crash Marc hit when typing a bad OpenAI key.
// crash the maintainer hit when typing a bad OpenAI key.
expect(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
@@ -139,7 +139,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
});
// v1.4 fix: the dropdown in /settings was not honoured — the test always
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
// ran against the SAVED provider (the maintainer reported "wirft was Komisches raus").
// The route now accepts a JSON override body and forwards the user's
// unsaved selection to resolveProviderForTest().
describe("POST /api/ai/test — dropdown-aware override", () => {
+1 -1
View File
@@ -95,7 +95,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
// which is exactly how Marc encountered this when typing a bad
// which is exactly how the maintainer encountered this when typing a bad
// OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0;
let outboundStatus: number;
+34 -19
View File
@@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
computeHealthScore,
@@ -69,19 +70,30 @@ export const GET = apiHandler(async () => {
let bpInTargetPct: number | null = null;
let bpInTargetPct7d: number | null = null;
let bpInTargetPct30d: number | null = null;
let bpInTargetPctAllTime: number | null = null;
/**
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
* pcts so the BD-Zielbereich tile's comparison-overlay caption
* stops mismatching its math with its label. The tile's
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
* matching the user's `comparisonBaseline` selection, never
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
*/
let bpInTargetPctPriorMonth: number | null = null;
let bpInTargetPctPriorYear: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
// v1.4.19 A1 — fetch ALL paired BP rows, not just the trailing 30
// days. Up to v1.4.18 we filtered to the last 30 days at the DB
// level and the headline (`bpInTargetPct`) was routed through
// `windows.last30Days?.pct` — making the headline a literal copy
// of the `30T` sub-value. On a busy account (e.g. 572 paired
// readings with recent 30d = 50 %, all-time ≈ 11 %) the tile
// pinned 50/50/50 and looked algorithmically broken. The windowed
// helper now also returns an independent `allTime` aggregate,
// which we surface as the headline so the three numbers can
// diverge naturally.
// v1.4.22 A1 — re-anchor the BD-Zielbereich tile headline to the
// last-30-day window. Up to v1.4.19 the headline pinned to the
// 30-day average (so 7d / 30d / total all read 50 %). v1.4.19 A1
// flipped the headline to all-time, which made the tile correct
// but emotionally wrong: the headline was the slowest-moving
// aggregate possible, punishing recent improvement. v1.4.22 A1
// re-routes the headline to last-30-days and surfaces 7d / 30d /
// all-time as a 3-line sub-row so power users still see the
// long-arc number without it dominating. The helper still returns
// every window — only the headline pick changed.
const [sysData, diaData] = await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
@@ -94,9 +106,12 @@ export const GET = apiHandler(async () => {
]);
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
bpInTargetPct = windows.allTime?.pct ?? null;
bpInTargetPct = windows.last30Days?.pct ?? null;
bpInTargetPct7d = windows.last7Days?.pct ?? null;
bpInTargetPct30d = windows.last30Days?.pct ?? null;
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
}
// Per-context glucose summaries (canonical mg/dL).
@@ -150,6 +165,9 @@ export const GET = apiHandler(async () => {
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
bpInTargetPctAllTime,
bpInTargetPctPriorMonth,
bpInTargetPctPriorYear,
glucoseByContext,
correlations,
healthScore,
@@ -316,6 +334,11 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
return { bpCompliance, moodPulse, weightWeekday };
}
// v1.4.22 W5 reconcile (Code-MED-3) — `berlinDayKey()` lifted to
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
// bucketing shares the same Europe/Berlin contract. The
// `weekday: "short"` formatter still lives here because it's only
// used by `berlinIsoWeekday()` below.
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
@@ -324,14 +347,6 @@ const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
weekday: "short",
});
function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
function dateFromBerlinKey(key: string): Date {
// Anchor to UTC midnight — the date is a sortable bucket label rather
// than a wall-clock timestamp, so DST drift is irrelevant.
@@ -17,6 +17,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
+5 -1
View File
@@ -76,7 +76,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
// `hl_onboarding` cookie itself; pass the user's onboarding state
// through so the proxy can short-circuit the redirect before the
// page hydrates.
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
await auditLog("auth.login.password", {
userId: user.id,
+8
View File
@@ -2,12 +2,20 @@ import { apiSuccess } from "@/lib/api-response";
import { getGravatarUrl } from "@/lib/gravatar";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy
// sessions that predate the cookie. New sessions anchor the cookie
// inside `createSession` itself, so this is no longer the primary
// write path; it just makes sure pre-v1.4.22 sessions get their
// cookie set on the first /me roundtrip after the upgrade.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
annotate({ action: { name: "auth.me" } });
return apiSuccess({
@@ -15,6 +15,7 @@ vi.mock("@/lib/auth/passkey", () => ({
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
@@ -65,8 +65,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("User not found", 404);
}
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` anchors the
// `hl_onboarding` cookie itself; pass the user's onboarding state
// through so the proxy short-circuits the redirect before
// hydration.
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
await auditLog("auth.login.passkey", {
userId: user.id,
+4 -1
View File
@@ -75,10 +75,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: { passwordHash: newHash },
});
// Invalidate all existing sessions and create a fresh one
// Invalidate all existing sessions and create a fresh one.
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
// `hl_onboarding` cookie itself.
await destroyAllSessions(user.id);
await createSession(
user.id,
user.onboardingCompletedAt == null,
getClientIp(request),
request.headers.get("user-agent"),
);
+5 -2
View File
@@ -94,9 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// Create session immediately
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
// users are always pending so the proxy redirects on first
// navigation instead of waiting for hydration.
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
await createSession(user.id, true, ip, ua);
await auditLog("auth.register", {
userId: user.id,
@@ -2,7 +2,7 @@
* PUT /api/dashboard/chart-overlay-prefs — v1.4.18
*
* Persist a single chart's overlay-prefs (3 toggles) onto the existing
* `User.dashboardWidgetsJson` blob. Marc rejected v1.4.16's always-on
* `User.dashboardWidgetsJson` blob. the maintainer rejected v1.4.16's always-on
* chart overlays (gradient fill, personal-baseline reference line,
* target-zone shading); the new pattern is per-chart switches that the
* user opts into independently and that persist per user.
+1 -1
View File
@@ -27,7 +27,7 @@ import type { NextRequest } from "next/server";
// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
// one here makes the PUT 422 silently — the toast surfaces "Layout
// konnte nicht gespeichert werden" — and Marc's tile-toggle looks
// konnte nicht gespeichert werden" — and the user's tile-toggle looks
// like it does nothing because the save round-trip never completes.
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so
// every save attempted with the achievements widget present (i.e.
+82 -57
View File
@@ -60,6 +60,8 @@ import {
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { createSseStream } from "@/lib/sse/create-stream";
/**
* Hard cap on total turns kept inside the per-call prompt window.
@@ -260,18 +262,61 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
throw err;
}
const replyText = (result.content ?? "").trim();
if (!replyText) {
const rawReply = (result.content ?? "").trim();
if (!rawReply) {
return streamProviderError({ code: "coach.provider.empty" });
}
// v1.4.22 — strip the optional `---KEYVALUES---` … `---END---`
// sentinel out of the prose. The stripped prose is what we stream
// to the client and persist; the parsed entries enrich the
// provenance envelope so the UI can render the collapsible
// "Worauf bezieht sich das?" disclosure.
const sentinel = parseKeyValuesSentinel(rawReply);
const proseAfterStrip = sentinel.prose.trim();
// v1.4.22 W5 reconcile (Code-H1) — when the model emits a
// sentinel-only / malformed reply, `sentinel.prose` is empty after
// stripping. The previous fallback `sentinel.prose.trim() || rawReply`
// surfaced raw `---KEYVALUES---` markers to the user. The empty-prose
// condition signals an unusable provider response: short-circuit to
// the structured `coach.provider.empty` error frame instead of
// streaming the raw sentinel body.
if (!proseAfterStrip) {
annotate({
action: { name: "coach.keyvalues.parse_failed" },
meta: {
kept: sentinel.keyValues.length,
reason: "empty_prose_after_strip",
promptVersion: PROMPT_VERSION,
},
});
return streamProviderError({ code: "coach.provider.empty" });
}
const replyText = proseAfterStrip;
const enrichedProvenance =
sentinel.keyValues.length > 0
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
: snapshot.provenance;
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
// sentinel format has drifted, but pass the prose through
// unchanged.
annotate({
action: { name: "coach.keyvalues.parse_failed" },
meta: {
kept: sentinel.keyValues.length,
promptVersion: PROMPT_VERSION,
},
});
}
// Persist the assistant message BEFORE we begin streaming; if the
// client disconnects we still have the canonical row.
const assistantMessage = await appendMessage({
conversationId: workingConversationId,
role: "assistant",
content: replyText,
metricSource: snapshot.provenance,
metricSource: enrichedProvenance,
providerType: workingProviderType,
promptVersion: PROMPT_VERSION,
});
@@ -296,29 +341,23 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
});
// ── Stream the body to the client ────────────────────────────
const stream = new ReadableStream<Uint8Array>({
start(controller) {
try {
for (const tok of tokeniseForStreaming(replyText)) {
controller.enqueue(encodeFrame({ type: "token", token: tok }));
}
controller.enqueue(
encodeFrame({
type: "provenance",
metricSource: snapshot.provenance,
}),
);
controller.enqueue(
encodeFrame({
type: "done",
conversationId: workingConversationId,
messageId: assistantMessage.id,
}),
);
} finally {
controller.close();
}
},
const stream = createSseStream((controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
controller.enqueue(encodeFrame({ type: "token", token: tok }));
}
controller.enqueue(
encodeFrame({
type: "provenance",
metricSource: enrichedProvenance,
}),
);
controller.enqueue(
encodeFrame({
type: "done",
conversationId: workingConversationId,
messageId: assistantMessage.id,
}),
);
});
return new Response(stream, { status: 200, headers: SSE_HEADERS });
@@ -368,43 +407,29 @@ async function streamRefusal(args: {
promptVersion: PROMPT_VERSION,
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
try {
controller.enqueue(
encodeFrame({ type: "token", token: args.refusalText }),
);
controller.enqueue(
encodeFrame({
type: "done",
conversationId,
messageId: refusalMessage.id,
}),
);
} finally {
controller.close();
}
},
const stream = createSseStream((controller) => {
controller.enqueue(encodeFrame({ type: "token", token: args.refusalText }));
controller.enqueue(
encodeFrame({
type: "done",
conversationId,
messageId: refusalMessage.id,
}),
);
});
return new Response(stream, { status: 200, headers: SSE_HEADERS });
}
function streamProviderError(args: { code: string }): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
try {
controller.enqueue(
encodeFrame({
type: "error",
code: args.code,
message: args.code,
}),
);
} finally {
controller.close();
}
},
const stream = createSseStream((controller) => {
controller.enqueue(
encodeFrame({
type: "error",
code: args.code,
message: args.code,
}),
);
});
// Status 200 so the streaming client reads the SSE body and parses
// the structured `error` frame (HTTP-status branches drop the
@@ -223,7 +223,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
});
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
// Marc reported the previous 2/h was too aggressive when iterating on
// the maintainer reported the previous 2/h was too aggressive when iterating on
// settings. The 10/h ceiling is the new default; the env override lets
// operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
+99 -3
View File
@@ -14,6 +14,7 @@ import {
import { calculateCompliance } from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
@@ -56,6 +57,19 @@ interface TargetItem {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
* is the last 30 days of values (chronological, oldest → newest) for
* the target's primary metric, rounded to one decimal. Derived metrics
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
* field when the page already renders a richer status surface.
* `deltaVsLastMonth` is the signed difference between the current
* 30-day average and the prior 30-day average (current − prior),
* null when either window has fewer than 3 points to keep the
* comparison honest.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
details?: {
medications?: Array<{
name: string;
@@ -98,17 +112,28 @@ export const GET = apiHandler(async () => {
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
// can render a "Δ vs. last month" caption alongside its sparkline.
// We fetch 60 days in one query to keep the route round-trip count
// identical to the previous version.
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 30 days + the latest for each type
const recentMeasurements = await prisma.measurement.findMany({
// Fetch all measurements in the last 60 days + the latest for each type
const windowedMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
measuredAt: { gte: sixtyDaysAgo },
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
const recentMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt >= thirtyDaysAgo,
);
const priorMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt < thirtyDaysAgo,
);
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
@@ -163,6 +188,50 @@ export const GET = apiHandler(async () => {
return "stable";
}
/**
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
* the last 30 days of values for a measurement type into a daily
* mean series (chronological, oldest → newest). The delta is the
* signed difference between the current 30-day window's average
* and the prior 30-day window's average. Both helpers skip the
* computation when there are fewer than 3 points in a window so
* the comparison stays honest on cold-start accounts.
*/
function sparklinePoints(type: MeasurementType): number[] | null {
const data = recentMeasurements
.filter((m) => m.type === type)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
if (data.length < 3) return null;
// v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar
// day, not UTC. A 23:30-Berlin reading on Tuesday otherwise
// lands in Wednesday's UTC bucket and the sparkline drifts a
// day on cross-DST boundaries. Aligns with `berlinDayKey()`
// already used by the dashboard analytics route.
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of data) {
const day = berlinDayKey(m.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += m.value;
bucket.count += 1;
byDay.set(day, bucket);
}
return Array.from(byDay.values()).map(
(b) => Math.round((b.sum / b.count) * 10) / 10,
);
}
function deltaVsLastMonth(type: MeasurementType): number | null {
const recentOfType = recentMeasurements.filter((m) => m.type === type);
const priorOfType = priorMeasurements.filter((m) => m.type === type);
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
const recentAvg =
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
const priorAvg =
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
return Math.round((recentAvg - priorAvg) * 10) / 10;
}
// Build target items
const targets: TargetItem[] = [];
@@ -185,6 +254,8 @@ export const GET = apiHandler(async () => {
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
points30d: sparklinePoints("WEIGHT"),
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
});
// 2. Blood Pressure (sys/dia combined)
@@ -210,6 +281,8 @@ export const GET = apiHandler(async () => {
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
// Extra fields for diastolic
} as TargetItem);
@@ -276,6 +349,8 @@ export const GET = apiHandler(async () => {
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
classification: pulseClassification,
source: pulseTarget.source,
points30d: sparklinePoints("PULSE"),
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
});
// 4. Sleep Duration
@@ -295,6 +370,8 @@ export const GET = apiHandler(async () => {
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
points30d: sparklinePoints("SLEEP_DURATION"),
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
});
// 5. BMI (derived from weight + height)
@@ -318,6 +395,19 @@ export const GET = apiHandler(async () => {
bmiClassification = { category: cls.category, color: cls.color };
}
// BMI sparkline / delta derive from the weight series \u2014 divide each
// point by height\u00B2 so the y-axis matches the BMI range bar above.
const weightPoints = sparklinePoints("WEIGHT");
const bmiPoints =
weightPoints != null
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
: null;
const weightDelta = deltaVsLastMonth("WEIGHT");
const bmiDelta =
weightDelta != null
? Math.round((weightDelta / heightSq) * 10) / 10
: null;
targets.push({
type: "BMI",
label: "BMI",
@@ -328,6 +418,8 @@ export const GET = apiHandler(async () => {
range: { min: 18.5, max: 24.9 },
classification: bmiClassification,
source: "WHO",
points30d: bmiPoints,
deltaVsLastMonth: bmiDelta,
});
}
@@ -353,6 +445,8 @@ export const GET = apiHandler(async () => {
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
points30d: sparklinePoints("BODY_FAT"),
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
});
// 7. Activity Steps
@@ -371,6 +465,8 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
points30d: sparklinePoints("ACTIVITY_STEPS"),
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
+2 -2
View File
@@ -22,7 +22,7 @@ import { prisma } from "@/lib/db";
* comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
* verification recipe (Marc had to SSH and check digests).
* verification recipe (the maintainer had to SSH and check digests).
*
* Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record<string,
@@ -104,7 +104,7 @@ function hasValidSecret(request: NextRequest): boolean {
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is
* safe even on a first-deploy where Marc hasn't wired the bot yet.
* safe even on a first-deploy where the maintainer hasn't wired the bot yet.
*/
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -56,5 +57,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
data,
});
// v1.4.22 C4 — clear the proxy-readable onboarding cookie so the
// next navigation drops the /onboarding redirect immediately.
await setOnboardingPendingCookie(false);
return apiSuccess({ completed: true });
});
+1 -1
View File
@@ -204,7 +204,7 @@
* bar. Used by the mobile section strips in `<AdminShell>` and
* `<SettingsShell>` so 13 admin / 10 settings entries can swipe
* horizontally without painting an always-on scrollbar at the top of
* every admin/settings page (a recurring Marc complaint — most
* every admin/settings page (a recurring maintainer complaint — most
* recently mistaken for a `/admin/api-tokens` table scrollbar in
* v1.4.16/17 because the strip sits right above the api-tokens card).
*
+104 -37
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -49,7 +50,6 @@ import { CorrelationRow } from "@/components/insights/correlation-row";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { toWeekISO } from "@/lib/insights/week-iso";
import { CompareToggle } from "@/components/comparison/compare-toggle";
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
// wrapper so the bundle only lands once a correlation card actually renders.
// Every scatter card sits inside a `length >= 5` gate and below the fold,
@@ -562,7 +562,7 @@ export default function InsightsPage() {
// preference so a toggle flip in Settings → Dashboard updates both
// surfaces atomically.
const { data: layoutData } = useQuery({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("Failed");
@@ -950,6 +950,15 @@ export default function InsightsPage() {
return (
<div className="space-y-8">
{/* v1.4.22 A5 — section tabs lift above the hero strip so the
user sees the metric-tab nav before scrolling. The nav itself
remains a sticky scroll-anchored strip — clicking a tab
scrolls to the matching section, scrolling highlights the
active tab. Hero + Daily Briefing always render below the
nav; the metric tabs control which sub-sections are visible
in the user's viewport. */}
<InsightsSectionNav />
<HeroStrip
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
@@ -979,14 +988,13 @@ export default function InsightsPage() {
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
metaSlot={<CompareToggle />}
/>
{/* v1.4.20 phase B3 — Correlation discovery row. Three pre-defined
hypotheses (BP × compliance, mood × pulse, weight × weekday) gated
on n >= 14 + p < 0.05; cards below the bar render a per-card
empty-state. The row-level disclaimer ("Patterns are observational,
not causal …") sits below the grid once. */}
empty-state. The row-level disclaimer ("Relationships are
observational, not causal …") sits below the grid once. */}
{analytics?.correlations && (
<CorrelationRow results={analytics.correlations} />
)}
@@ -1010,9 +1018,7 @@ export default function InsightsPage() {
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
<InsightsSectionNav />
<section id="section-general" className="scroll-mt-28 space-y-2">
<section id="section-general" className="scroll-mt-16 space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.generalStatusTitle")}
@@ -1036,7 +1042,7 @@ export default function InsightsPage() {
</section>
{/* Section 3: Blood pressure */}
<section id="section-bp" className="scroll-mt-28 space-y-4">
<section id="section-bp" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.bloodPressureSectionTitle")}
@@ -1062,7 +1068,15 @@ export default function InsightsPage() {
annotations={bpStoryboardAnnotations}
/>
<div className="grid gap-4 xl:grid-cols-2">
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
100 % width. When the mood section is hidden the BP-medication
card collapses to full-width instead of leaving the right
half empty. */}
<div
className={
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
}
>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -1242,7 +1256,7 @@ export default function InsightsPage() {
</section>
{/* Section 4: Weight */}
<section id="section-weight" className="scroll-mt-28 space-y-4">
<section id="section-weight" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.weightSectionTitle")}
@@ -1264,7 +1278,13 @@ export default function InsightsPage() {
compareBaseline={compareBaseline}
/>
<div className="grid gap-4 xl:grid-cols-2">
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
100 % width. Mirrors the BP-section grid normalisation. */}
<div
className={
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
}
>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -1406,7 +1426,7 @@ export default function InsightsPage() {
</section>
{/* Section 5: Pulse */}
<section id="section-pulse" className="scroll-mt-28 space-y-4">
<section id="section-pulse" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.pulseSectionTitle")}
@@ -1441,7 +1461,7 @@ export default function InsightsPage() {
{/* Section: Mood */}
{showMoodSection && (
<section id="section-mood" className="scroll-mt-28 space-y-4">
<section id="section-mood" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.moodSectionTitle")}
@@ -1469,7 +1489,7 @@ export default function InsightsPage() {
)}
{/* Section 6: Medication Compliance */}
<section id="section-meds" className="scroll-mt-28 space-y-4">
<section id="section-meds" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.medicationCompliance")}
@@ -1485,7 +1505,15 @@ export default function InsightsPage() {
</div>
{data.medications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
/* v1.4.22 A4 — row-fill rule: a single medication card fills
100 % width instead of half the row. */
<div
className={
data.medications.length >= 2
? "grid gap-4 sm:grid-cols-2"
: "grid gap-4"
}
>
{data.medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
return (
@@ -1562,7 +1590,7 @@ export default function InsightsPage() {
</section>
{/* Section 7: BMI */}
<section id="section-bmi" className="scroll-mt-28 space-y-4">
<section id="section-bmi" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.bmiSectionTitle")}
@@ -1702,10 +1730,19 @@ function InsightsSectionNav() {
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
// highest intersectionRatio in the band rather than the
// observer-supplied last entry. Three sections briefly
// visible during a fast scroll otherwise made the active
// pill jump to whichever one came last in the batch.
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
const top = visible[0];
if (top) {
setActiveId((current) =>
current === top.target.id ? current : top.target.id,
);
}
},
{ rootMargin: "-30% 0px -60% 0px" },
@@ -1721,26 +1758,56 @@ function InsightsSectionNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
if (!el) return;
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
// `prefers-reduced-motion`; honour the user's OS-level pref.
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
});
}
return (
<nav className="bg-background/80 sticky top-0 z-30 -mx-4 overflow-x-auto border-b px-4 py-2 backdrop-blur-sm md:-mx-6 md:px-6">
// v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility +
// sticky-strip polish. Notable changes:
// - aria-label so screen-reader landmark traversal hears the
// rail's purpose ("Skip to section" / "Zu Abschnitt springen").
// - bg-background/95 instead of /80 to kill the hero-glow bleed
// during scroll.
// - Drop the `-mx-4 / md:-mx-6` negative margin; the parent
// container handles horizontal padding so 280px (Galaxy Fold)
// doesn't get a ghost scrollbar.
// - Hide the inner overflow's scrollbar so the sticky strip
// reads as a single bar.
<nav
aria-label={t("insights.navAriaLabel")}
className={cn(
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
<div className="flex gap-2">
{SECTION_IDS.map((id) => (
<button
key={id}
onClick={() => scrollTo(id)}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
activeId === id
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
))}
{SECTION_IDS.map((id) => {
const isActive = activeId === id;
return (
<button
key={id}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
);
})}
</div>
</nav>
);
+1 -1
View File
@@ -191,7 +191,7 @@ export default function NotificationsPage() {
<thead>
<tr className="border-border border-b">
<th className="text-muted-foreground px-4 py-3 text-left text-xs font-medium tracking-wider uppercase">
{t("notifications.title")}
{t("notifications.eventColumn")}
</th>
{activeChannels.map((ch) => (
<th
+87 -11
View File
@@ -97,6 +97,21 @@ interface AnalyticsData {
*/
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
/**
* v1.4.22 A1 — long-arc all-time aggregate. After the headline
* re-anchor to last-30-days the all-time number lives as a sub-value
* on the BD-Zielbereich tile (alongside `7d` and `30d`).
*/
bpInTargetPctAllTime?: number | null;
/**
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
* pcts. The BD-Zielbereich tile's comparison-overlay caption picks
* `priorMonth` for `comparisonBaseline === "lastMonth"` and
* `priorYear` for `lastYear` so the rendered "Δ X% vs. last month"
* stays honest. Null when the prior window has no paired readings.
*/
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, DataSummaryType>;
}
@@ -193,7 +208,7 @@ export default function DashboardPage() {
});
const { data: layoutData } = useQuery({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("Failed");
@@ -315,7 +330,7 @@ export default function DashboardPage() {
// v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself
// self-handles the empty state (CTA → /achievements), so we only need
// the layout-toggle gate here. No data-floor check (the empty card is
// intentional — Marc wants the user to discover the feature).
// intentional — the maintainer wants the user to discover the feature).
const showAchievementsCard = isChartVisible("achievements");
// v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard
// insights preview by the layout toggle. The component itself
@@ -585,7 +600,7 @@ export default function DashboardPage() {
// BP is conceptually one signal but visually two tiles (sys + dia)
// so the user sees both numbers side by side at the same size as
// every other tile in the strip. v1.4.3 first attempted a
// combined tile with `secondary` values — Marc preferred two
// combined tile with `secondary` values — the maintainer preferred two
// distinct tiles with consistent symmetric widths.
trendCards.push({
id: "bp-sys",
@@ -794,6 +809,63 @@ export default function DashboardPage() {
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
and 30-day in-target shares: when the recent week is
above the recent month, the metric is improving (up-good
⇒ green arrow); when below, it's slipping. The
trend7Delta is the same number as the arrow's underlying
signal, surfaced as "(+5)" next to `7d:` so the tile
matches the (weight / BP / pulse) call-site contract.
Comparison overlay routes through the same global
`compareBaseline` / `tileCompareDelta` pipeline as every
other tile; we only have a single % series for the BP
tile (no DataSummary) so the prior-period delta uses
`bpInTargetPctAllTime` as the long-arc baseline — when
comparison is off the field stays null. */
const bp7 = data?.bpInTargetPct7d ?? null;
const bp30 = data?.bpInTargetPct30d ?? null;
const bpAll = data?.bpInTargetPctAllTime ?? null;
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
const bpPriorYear = data?.bpInTargetPctPriorYear ?? null;
const bpTrendDelta =
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
bpTrendDelta === null
? null
: {
slope: bpTrendDelta / 30,
direction:
bpTrendDelta > 0.5
? "up"
: bpTrendDelta < -0.5
? "down"
: "stable",
// Synthetic slope (last-7d minus last-30d) carries no
// R² — pin to 1 so existing TrendSlope consumers don't
// mis-interpret a `0` as "very low confidence".
confidence: 1,
};
// v1.4.22 W5 reconcile (Code-H2) — match the comparison
// window the user picked. `lastMonth` baseline ⇒ compare
// last30 against the now-60d…now-30d window (priorMonth);
// `lastYear` baseline ⇒ compare against the matching window
// shifted back 365 days. The previous shortcut subtracted
// `bpAll` regardless of baseline, which produced a number
// whose magnitude was honest but whose label ("vs. last
// month") lied to the user.
const bpComparePrior =
compareBaseline === "lastMonth"
? bpPriorMonth
: compareBaseline === "lastYear"
? bpPriorYear
: null;
const bpCompareDelta =
compareBaseline === "none" ||
bp30 === null ||
bpComparePrior === null
? null
: Math.round((bp30 - bpComparePrior) * 10) / 10;
trendCards.push({
id: "bpInTarget",
order: widgetOrder("bpInTarget"),
@@ -807,11 +879,15 @@ export default function DashboardPage() {
windowed analytics fields. Up to v1.4.17 these were
hard-coded to null and rendered "—" even when the
user had paired BP readings in both windows. */
avg7={data?.bpInTargetPct7d ?? null}
avg30={data?.bpInTargetPct30d ?? null}
slope30={null}
avg7={bp7}
avg30={bp30}
avgAllTime={bpAll}
slope30={bpSlope30}
trend7Delta={bpTrendDelta}
icon={Target}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={bpCompareDelta}
/>
),
});
@@ -1034,7 +1110,7 @@ export default function DashboardPage() {
// v1.4.15 phase-B4 — slotted at the user's chosen position via
// the layout `order`. Default order from
// `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row)
// which matches Marc's brief "below the chart row".
// which matches the maintainer's brief "below the chart row".
charts.push({
id: "achievements",
order: widgetOrder("achievements"),
@@ -1076,7 +1152,7 @@ export default function DashboardPage() {
return (
<>
{/* v1.4: dashboard tiles are *always* a single row.
* Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row
* maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
* tile strip breaks the visual hierarchy and reads like an
* Excel grid. Total width caps at the parent container —
* exactly the chart-width below. When the active tile count
@@ -1091,9 +1167,9 @@ export default function DashboardPage() {
{/* v1.4.16 Fix A5: hide the strip entirely when the user
turned off every tile. Until v1.4.15 the wrapper rendered
an empty grid even with zero tiles — visually a thin gap
Marc described as "awkward". Charts below still render so
the maintainer described as "awkward". Charts below still render so
the page is not empty; the tile-strip just goes away.
The constraint Marc named — "immer die gesamte Spalte
The constraint the maintainer named — "immer die gesamte Spalte
breit und immer der gleichen Höhe" — is preserved by the
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
continues to give every visible tile equal width / equal
@@ -1107,7 +1183,7 @@ export default function DashboardPage() {
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
// scrolling — Marc tested both and prefers the v1.3-era
// scrolling — the maintainer tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
+88 -2
View File
@@ -38,6 +38,13 @@ interface TargetData {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API
* skips both when a window has fewer than 3 readings so the comparison
* stays honest on a fresh account.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
details?: {
medications?: Array<{
name: string;
@@ -110,7 +117,7 @@ const GLUCOSE_TYPES = new Set([
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
* A7 wires this map so the German locale stops surfacing English
* status pills like "Low / On Target / Stable / Moderate" — the
* exact regression Marc reported.
* exact regression the maintainer reported.
*/
const STATUS_CATEGORY_KEY: Record<string, string> = {
// BMI
@@ -241,6 +248,60 @@ function getTargetSourceLink(target: TargetData): string | null {
return null;
}
/**
* v1.4.22 C1 — tiny inline sparkline. Each target card paints the last
* 30 days of values beneath the range bar so the page reads as a
* "current state + recent journey" surface rather than a static
* reference card. The SVG is dependency-free and renders in <1ms; we
* deliberately avoid pulling Recharts in here because every card on
* the page would mount its own ResponsiveContainer.
*
* The path stays in the dracula-purple token so the colour reads as a
* neutral trace, not a status signal — the range bar above already
* carries the green/yellow/red semantics and a second status colour
* on the sparkline would compete for the user's eye.
*/
function Sparkline({
points,
className,
}: {
points: number[];
className?: string;
}) {
if (points.length < 2) return null;
const min = Math.min(...points);
const max = Math.max(...points);
const span = Math.max(max - min, 0.0001);
const w = 100;
const h = 24;
const stepX = w / (points.length - 1);
const path = points
.map((value, i) => {
const x = i * stepX;
const y = h - ((value - min) / span) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className={`h-6 w-full ${className ?? ""}`}
aria-hidden="true"
data-slot="target-sparkline"
>
<path
d={path}
fill="none"
stroke="var(--dracula-purple)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
if (trend === "up") {
return <TrendingUp className="h-4 w-4 text-orange-400" />;
@@ -507,6 +568,31 @@ function TargetCard({
</div>
)}
{/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The
sparkline collapses to a thinner trace on mobile via the
shared 24px height, and the delta caption sits on its own
line so it stays scannable next to the range bar above. */}
{target.points30d && target.points30d.length >= 2 && (
<div className="space-y-1" data-slot="target-trend">
<Sparkline points={target.points30d} />
{target.deltaVsLastMonth != null && (
<p
className="text-muted-foreground text-xs"
data-slot="target-delta"
>
{Math.abs(target.deltaVsLastMonth) < 0.05
? t("targets.deltaVsLastMonthFlat", { unit: target.unit })
: t("targets.deltaVsLastMonth", {
delta:
(target.deltaVsLastMonth > 0 ? "+" : "−") +
Math.abs(target.deltaVsLastMonth).toFixed(1),
unit: target.unit,
})}
</p>
)}
</div>
)}
{isMedicationCompliance && medicationBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
@@ -649,7 +735,7 @@ export default function TargetsPage() {
});
return (
// v1.4.19 phase A7 — Marc reported "relativ viel Platz" wasted
// v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
// between the overview header and the first values on `/targets`.
// Tighten the rhythm from `space-y-8` (32 px) to `space-y-6`
// (24 px) — that matches the admin / settings pages.
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
* is always wider than a 393 CSS-px (Pixel-5) viewport, so v1.4.18
* phase A2 added the `no-scrollbar` class to the strip — Chromium /
* WebKit otherwise paint an always-on horizontal scrollbar at the top
* of every admin page, which Marc kept mis-attributing to the
* of every admin page, which the maintainer kept mis-attributing to the
* `/admin/api-tokens` table card right below it. Three previous fix
* attempts (v1.4.15 column-hide, v1.4.16 mobile-card-list, then this
* one) all targeted the table while the bar lived in the shell.
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
* v1.4 shared-admin page where 13 sections lived on one route; in
* v1.5 every section has its own dedicated route, so collapsing the
* only card on `/admin/api-tokens` hides the entire surface and
* confused Marc more than it helped.
* confused the maintainer more than it helped.
*/
vi.mock("next/navigation", () => ({
@@ -94,7 +94,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
it("mobile card-list has no overflow-x-auto wrapper", () => {
const html = render();
// Mobile cards must let content wrap within the card rather than
// forcing a scroll container — that was the v1.4.15 bug Marc
// forcing a scroll container — that was the v1.4.15 bug the maintainer
// re-reported. Confirm there's no overflow-x-auto INSIDE the
// mobile <ul>.
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
@@ -122,6 +122,32 @@ describe("ApiTokenOverviewSection — responsive", () => {
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
});
it("desktop date cells drop whitespace-nowrap (v1.4.22 C2 5th-attempt fix)", () => {
const html = render();
// The "Last used" + "Created" `<td>`s no longer carry
// `whitespace-nowrap`. The v1.4.19 production probe confirmed that
// class was the actual culprit behind the residual horizontal
// scrollbar at 768-1280px: a formatted date+time string
// ("05.05.2026, 21:46") overflowed its 12% colgroup allotment
// (~84px on a 700px content area) and won over `table-fixed`'s
// width contract, so the table's intrinsic width exceeded 100%
// and the wrapping `overflow-x-auto` painted a scrollbar. Letting
// the cell wrap to two lines costs one row of height but kills
// the bar.
const tableMatch = html.match(/<table[\s\S]*?<\/table>/);
expect(tableMatch).not.toBeNull();
const tableMarkup = tableMatch![0];
// Pick every <td> inside the desktop table that renders one of the
// two date columns (text-right + text-xs is unique to those cells)
// and assert none carries whitespace-nowrap.
const dateCellMatches =
tableMarkup.match(/<td[^>]*\btext-right\b[^>]*\btext-xs\b[^>]*>/g) ?? [];
expect(dateCellMatches.length).toBeGreaterThanOrEqual(2);
for (const cell of dateCellMatches) {
expect(cell).not.toContain("whitespace-nowrap");
}
});
it("mobile timestamp lines wrap (no nowrap on the meta paragraphs)", () => {
const html = render();
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
@@ -4,13 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix.
*
* Marc reported a "minimaler Scrollbar" still painting at the
* the maintainer reported a "minimaler Scrollbar" still painting at the
* bottom-right of the api-tokens page, even after the v1.4.18 phase
* A2 mobile-strip `no-scrollbar` fix. The remaining culprits are
* inside the section card itself: long token names, long usernames,
* and long permission strings can each push their parent past the
* viewport. Cumulatively this leaves the page in an "almost-fits"
* state where the visible scrollbar Marc sees is the page-level main
* state where the visible scrollbar the maintainer sees is the page-level main
* scroll picking up a fraction of pixels of horizontal scroll spill
* from the children.
*
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.15 phase A2: the `/admin` overview no longer renders the
* `StatusCardGrid` (the section-card grid Marc described as redundant
* `StatusCardGrid` (the section-card grid the maintainer described as redundant
* with the sidebar nav). The status-card component itself was removed
* from the codebase. This test guards the *new* overview composition:
* a welcome card, the system snapshot, and the recent-audit preview.
+1 -1
View File
@@ -153,7 +153,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
painted scrollbar at the top of every admin page; the
horizontal swipe + keyboard arrow scrolling still work, the
bar just doesn't draw. Without this the 13-section strip
would render an always-on scrollbar that Marc kept
would render an always-on scrollbar that the maintainer kept
mis-attributing to the api-tokens table below it. */}
<nav
aria-label={t("admin.shell.sectionsNav")}
@@ -20,7 +20,7 @@ import { type ApiTokenInfo } from "./_shared";
*
* v1.4.19 phase A7 (4th attempt) — earlier fixes (column-hide,
* mobile card-list, mobile-strip `no-scrollbar`) cleared every
* scrollbar Marc reported except a residual painted bar he kept
* scrollbar the maintainer reported except a residual painted bar he kept
* seeing at the bottom-right. Production probe confirmed no
* remaining horizontal-overflow culprits, but the cards still
* carried `break-all` next to `truncate` (dead code; `white-space:
@@ -55,6 +55,46 @@ function TruncatedCell({
);
}
/**
* v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active
* 3-way ternary is identical between the desktop table and the
* mobile card list; the only difference is `text-xs` vs `text-[10px]`.
* Centralise so the two surfaces stay in lock-step.
*/
function TokenStatusBadge({
token,
size,
}: {
token: ApiTokenInfo;
size: "sm" | "xs";
}) {
const { t } = useTranslations();
const expired =
token.expiresAt != null && new Date(token.expiresAt) < new Date();
const sizeClass = size === "xs" ? "text-[10px]" : "text-xs";
if (token.revoked) {
return (
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
{t("settings.tokenRevoked")}
</Badge>
);
}
if (expired) {
return (
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
{t("settings.tokenExpired")}
</Badge>
);
}
return (
<Badge
className={`bg-dracula-green/15 text-dracula-green shrink-0 ${sizeClass}`}
>
{t("common.active")}
</Badge>
);
}
/**
* F-18 (v1.4.19): the auto-login flow names tokens
* "web auto-login 2026-05-05T19:46:20.603Z" / "iOS auto-login …" by
@@ -63,8 +103,13 @@ function TruncatedCell({
* "iOS auto-login · 05.05.2026 19:46" while leaving any non-ISO suffix
* (manual names, device fingerprints, etc.) untouched.
*/
// v1.4.22 D / D-CR-M-03 — broaden the ISO match to include non-Z
// offsets (e.g. `+02:00`, `-05:30`). The previous regex only matched
// UTC (`Z`), so a token name carrying a local-tz timestamp slipped
// through unformatted and rendered the raw `2026-05-05T19:46:20.603+02:00`
// suffix instead of the locale-aware `05.05.2026 21:46` chunk.
const TOKEN_NAME_ISO_RE =
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(.*)$/;
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/;
// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the
// rest of the app shows timestamps in Berlin time (`format-locale.ts`),
@@ -99,7 +144,7 @@ export function ApiTokenOverviewSection() {
// section route. The previous pattern carried a "Collapse / Expand"
// toggle as an escape hatch from the v1.4 shared admin page where
// 13 sections lived together, but on a route that only renders this
// one card the toggle hides the entire surface. Marc reported it
// one card the toggle hides the entire surface. the maintainer reported it
// as "sinnlos" — gone.
const { data: tokens, isLoading } = useQuery({
queryKey: ["admin", "tokens"],
@@ -176,8 +221,6 @@ export function ApiTokenOverviewSection() {
</thead>
<tbody className="divide-border divide-y">
{tokens.map((token, i) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<tr
key={token.id}
@@ -208,26 +251,27 @@ export function ApiTokenOverviewSection() {
</div>
</td>
<td className="px-3 py-2 text-right">
{token.revoked ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("common.active")}
</Badge>
)}
<TokenStatusBadge token={token} size="sm" />
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{/* v1.4.22 C2 (5th attempt) — drop
`whitespace-nowrap` on the date cells. The
v1.4.19 A7 probe confirmed `formatDateTime`
renders "05.05.2026, 21:46" (~110px) which
exceeds the 12% `<col>` allotment (~84px on
a 700px content area); `whitespace-nowrap`
wins over `table-fixed`'s width contract,
so the table's intrinsic width exceeds 100%
and the wrapper's `overflow-x-auto` paints
the scrollbar the maintainer kept reporting
for the 5th time. Letting the date+time
wrap to two lines on narrow viewports costs
one row of height but eliminates the bar. */}
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
{formatDate(token.createdAt)}
</td>
</tr>
@@ -246,8 +290,6 @@ export function ApiTokenOverviewSection() {
data-testid="admin-tokens-mobile-list"
>
{tokens.map((token) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<li
key={token.id}
@@ -260,25 +302,7 @@ export function ApiTokenOverviewSection() {
value={formatTokenName(token.name)}
className="min-w-0 flex-1 font-medium"
/>
{token.revoked ? (
<Badge
variant="destructive"
className="shrink-0 text-[10px]"
>
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge
variant="destructive"
className="shrink-0 text-[10px]"
>
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green shrink-0 text-[10px]">
{t("common.active")}
</Badge>
)}
<TokenStatusBadge token={token} size="xs" />
</div>
<TruncatedCell
value={token.user.username}
+4 -1
View File
@@ -115,7 +115,10 @@ export function RemindersSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Clock className="text-primary h-5 w-5" />
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
<Bell className="text-primary h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.medicationReminders")}
</div>
@@ -311,7 +311,7 @@ export function UserManagementSection() {
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
{t("admin.userName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.userEmail")}
@@ -11,7 +11,7 @@ import {
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rejected the always-on overlays from B1a (gradient, baseline,
* the maintainer rejected the always-on overlays from B1a (gradient, baseline,
* target-zone shading) and asked for a per-chart switch surface so a
* user can toggle "7-Tage-Trend / Trend-Pfeil / Zielbereich" on or off
* for each chart independently. The popover is anchored top-right of
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
/**
* v1.4.18 — Default overlay state on chart wrappers.
*
* Marc rejected the always-on personal-baseline reference line that B1a
* the maintainer rejected the always-on personal-baseline reference line that B1a
* painted on every chart. The default surface for a new chart should be
* a clean line + axes + tooltip — no baseline, no target-zone shading,
* no trend line. Overlays are user-opt-in via the per-chart settings
@@ -123,7 +123,7 @@ describe("computePersonalBaseline()", () => {
describe("<HealthChart> v1.4.18 clean-line revert", () => {
it("does NOT paint a gradient fill under the line (clean line only)", async () => {
// v1.4.18 reverts B1a's gradient-area treatment. Marc explicitly
// v1.4.18 reverts B1a's gradient-area treatment. the maintainer explicitly
// rejected the soft-color fill below the line: the line itself is
// the chart, no painted background under it. We assert the SSR
// output ships no `<linearGradient>` defs and no `chart-gradient`
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
/**
* v1.4.18 — MoodChart clean-line revert (gradient + emoji rolled back).
*
* Marc rejected B1a's coloured area fill below the chart line and the
* the maintainer rejected B1a's coloured area fill below the chart line and the
* smiley/emoji glyphs at every data point. The line stroke + Dracula
* tokens stay; the chart now paints a clean monotone line with simple
* Recharts dots.
@@ -67,7 +67,7 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
});
it("does NOT render emoji glyphs at data points (source check)", async () => {
// Marc explicitly rejected smileys, especially in the mood chart.
// the maintainer explicitly rejected smileys, especially in the mood chart.
// Recharts does not paint <Line> dots during SSR (the dot callback
// only fires after first client render), so the emoji wouldn't
// surface in renderToStaticMarkup output anyway. Inspecting the
@@ -7,7 +7,7 @@ import { join } from "node:path";
* chart in a `<ResponsiveContainer>` whose default
* `touch-action: auto` makes the browser wait for the chart's JS
* touchmove handler before starting page scroll. On slow devices that
* causes Marc's "Scrolling-Hänger im Dashboard auf Charts".
* causes the maintainer's "Scrolling-Hänger im Dashboard auf Charts".
*
* Fix: every chart's wrapper `<div>` (the one that contains the
* `<ResponsiveContainer>`) must carry the Tailwind `touch-pan-y`
@@ -9,7 +9,7 @@ import { I18nProvider } from "@/lib/i18n/context";
* v1.4.15 Fix 4 — "7-Tage-Schnitt" → "7-Tage-Trend".
*
* The avg7 line on every dashboard tile used to read "7d: 80.5" /
* "7T: 80,5" — visually an *average*. Marc wanted it to convey
* "7T: 80,5" — visually an *average*. the maintainer wanted it to convey
* *direction* over the past week instead, so the label flips to
* "7d trend" / "7T-Trend" and a signed delta indicator paints next
* to the value with metric-aware colour:
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Target } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.22 A2 — BD-Zielbereich tile feature parity.
*
* The BP-in-target tile now ships the same set of features every other
* tile already has:
* - the optional `avgAllTime` sub-value renders next to `7d` and
* `30d` so the long-arc number stays visible after v1.4.22 A1
* re-anchored the headline to the last-30-day window.
* - the trend arrow + 7-day-trend chip use a synthesised slope from
* the difference between the 7-day and 30-day in-target shares.
* - the comparison overlay flows through the same `compareBaseline`
* / `compareDelta` props the dashboard wires for every other tile.
*
* Other tiles leave `avgAllTime` undefined so the third sub-row never
* renders — pinned via the negative case at the bottom.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const baseProps = {
label: "BP in target",
latest: 50,
unit: "%",
avg7: 55,
avg30: 50,
slope30: null,
icon: Target,
directionSentiment: "up-good" as const,
};
describe("<TrendCard> avgAllTime sub-value (v1.4.22 A2)", () => {
it("renders the all-time label + value when `avgAllTime` is provided", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={11} />);
// The slot is keyed via `data-slot` so callers can target it
// without scraping translated copy.
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain("All-time");
// The headline (50 %) is the 30-day pct after v1.4.22 A1; the
// all-time number lives in the third sub-row. Both must render
// (the formatter emits a trailing `.0` for integer values via the
// shared `useFormatters().number(_, 1)` contract — same shape as
// every other tile uses).
expect(html).toContain(">50.0<");
expect(html).toContain(">11.0<");
});
it("paints the all-time value with the supplied color class", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
avgAllTimeColorClass="text-dracula-orange"
/>,
);
expect(html).toContain("text-dracula-orange");
});
it("renders a dash for a null `avgAllTime` (no data yet) instead of a literal 'null'", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={null} />);
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain(">—<");
expect(html).not.toContain("null");
});
it("does NOT render the all-time slot when `avgAllTime` is undefined (other tiles untouched)", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).not.toContain('data-slot="trend-card-all-time"');
});
it("ships the trend arrow when a synthetic slope is provided", () => {
// Synthetic slope: 7d (55) above 30d (50) → up direction → green
// arrow under the up-good sentiment.
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
avgAllTime={11}
/>,
);
expect(html).toContain("text-dracula-green");
});
it("ships the 7-day-trend chip when a delta is provided", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
trend7Delta={5}
avgAllTime={11}
/>,
);
// The TrendCard renders the delta as e.g. `(+5.0)` next to `7d:`.
expect(html).toMatch(/\(\+5\.0\)/);
});
it("ships the comparison overlay when comparison is active", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
compareBaseline="lastMonth"
compareDelta={4}
/>,
);
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain("+4.0");
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { LineChart as LineChartIcon } from "lucide-react";
/**
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
*
* Per Marc's research §1A — "personal-baseline framing first" — when a
* Per the maintainer's research §1A — "personal-baseline framing first" — when a
* user has only 1-2 data points there's nothing useful to plot, so we
* paint a friendly hint instead of a near-empty canvas. The hint reuses
* the `lucide-react/LineChart` glyph the rest of the app uses for chart
@@ -19,7 +19,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rolled back B1a's always-on chart overlays (gradient fills,
* the maintainer rolled back B1a's always-on chart overlays (gradient fills,
* personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface instead. Each dashboard chart card mounts
* one of these in its top-right corner (a settings-cog dropdown) so the
@@ -30,7 +30,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
* - showTrendIndicator — the "7-day trend" overlay (moving average +
* numeric Δ chip in the header)
* - showTrendArrow — the trend regression line + arrow direction
* hint + personal-baseline reference line. Marc's rule: the mean
* hint + personal-baseline reference line. the maintainer's rule: the mean
* line only paints when a trend is being displayed, so we fold
* the baseline into this toggle.
* - showTargetRange — the target-zone shading + reference lines
+2 -2
View File
@@ -842,7 +842,7 @@ export function HealthChart({
// v1.4.16 Fix A8b: when the visible window is long (e.g. "All"
// on a multi-year account), the per-week delta becomes vanishingly
// small and rounds to ±0.0 in the formatter — Marc's complaint
// small and rounds to ±0.0 in the formatter — the maintainer's complaint
// "Wenn ich alle anklicke, dann wird einfach Null Veränderung
// angezeigt" was exactly this: a slope of ~0.5 kg/year prints
// as "+0.0 kg/week" and the user reads it as "no change". For
@@ -1194,7 +1194,7 @@ export function HealthChart({
/>
))}
{/* v1.4.18 — personal-baseline reference line is now
opt-in via the Trend toggle. Marc rejected the
opt-in via the Trend toggle. the maintainer rejected the
always-on dashed mean line; it now only paints when
the user actively shows the trend overlay (matching
his rule: "only when a trend is being displayed").
+2 -2
View File
@@ -475,7 +475,7 @@ export function MoodChart({
5: t("charts.moodLabel5"),
};
// v1.4.18 — emoji glyph map removed. Marc explicitly rejected
// v1.4.18 — emoji glyph map removed. the maintainer explicitly rejected
// smileys in the mood chart; the line now uses plain Recharts dots
// and the y-axis already labels each integer (very low / low / okay
// / good / great) so the chart is fully scannable without a glyph.
@@ -632,7 +632,7 @@ export function MoodChart({
/>
))}
{/* v1.4.18 — personal-baseline gated behind the
Trend toggle (Marc: "only when a trend is being
Trend toggle (maintainer: "only when a trend is being
displayed"). Default OFF. */}
{showTrend && personalBaseline != null && (
<ReferenceLine
+86 -3
View File
@@ -87,6 +87,17 @@ interface TrendCardProps {
*/
compareBaseline?: ComparisonBaseline;
compareDelta?: number | null;
/**
* v1.4.22 A2 — optional third sub-value rendered alongside `7d` and
* `30d`. Used by the BD-Zielbereich tile so the all-time aggregate
* stays visible after v1.4.22 A1 re-anchored the headline to the
* 30-day window. Pass the i18n key for the label (defaults to
* `charts.avgAllTimeShort`); other tiles leave both fields undefined
* so the third sub-row never renders.
*/
avgAllTime?: number | null;
avgAllTimeLabelKey?: string;
avgAllTimeColorClass?: string;
}
export function TrendCard({
@@ -106,6 +117,9 @@ export function TrendCard({
trend7Delta = null,
compareBaseline = "none",
compareDelta = null,
avgAllTime,
avgAllTimeLabelKey = "charts.avgAllTimeShort",
avgAllTimeColorClass,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -178,7 +192,7 @@ export function TrendCard({
};
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
// when the call site supplies a delta. Marc's v1.4.15 feedback —
// when the call site supplies a delta. the maintainer's v1.4.15 feedback —
// "7-Tage-Schnitt" sounds like an average, but the value next to it
// is now a TREND. Distinct keys keep the label change in i18n.
const avg7LabelKey =
@@ -216,9 +230,20 @@ export function TrendCard({
line below the latest value so the tile stays scannable on
mobile viewports (no horizontal-scroll, no overlap with the
existing 7d/30d row). Suppressed when comparison is off OR
we don't have enough data to compute a delta. */}
we don't have enough data to compute a delta.
v1.4.22 W5 reconcile (Design-H2) — on `<sm` viewports the
BP-in-target tile (the only consumer of `avgAllTime`) packs
three sub-rows + this callout into the height budget of
every other tile. Hide the standalone callout at `<sm` for
BP-in-target tiles; the secondary row below combines
all-time + comparison-delta into a single line that stays
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
full layout returns at `>=sm`. */}
{compareBaseline !== "none" && compareDelta != null && (
<div className="mt-1">
<div
className={cn("mt-1", avgAllTime !== undefined && "hidden sm:block")}
>
<span
className={cn(
"text-xs font-medium tabular-nums",
@@ -293,7 +318,65 @@ export function TrendCard({
</span>
)}
</span>
{/* v1.4.22 A2 — optional third sub-value (e.g. all-time aggregate
for BD-Zielbereich after v1.4.22 A1 re-anchored the headline
to last-30 days). Other tiles leave `avgAllTime` undefined so
the third sub-row never renders.
v1.4.22 W5 reconcile (Design-H2) — full label only at
`>=sm`; on `<sm` the all-time number moves to the
combined secondary row below so the tile keeps the same
vertical density as every other trend card. */}
{avgAllTime !== undefined && (
<span data-slot="trend-card-all-time" className="hidden sm:inline">
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
)}
</div>
{/* v1.4.22 W5 reconcile (Design-H2) — combined secondary row
for `<sm` viewports. Renders the all-time aggregate AND
(when comparison is on) the comparison delta on a single
line: "All-time 11% · vs −0.4 last month". Disappears at
`>=sm` because the full layout above takes over. */}
{avgAllTime !== undefined && (
<div
data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 text-xs sm:hidden"
>
<span>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
{compareBaseline !== "none" && compareDelta != null && (
<>
<span aria-hidden="true"> · </span>
<span
className={cn(
"font-medium tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta-mobile"
data-compare-baseline={compareBaseline}
>
{`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</span>
</>
)}
</div>
)}
</TooltipProvider>
</div>
);
@@ -88,7 +88,7 @@ describe("<CorrelationCard>", () => {
it("renders the empty-state when status=insufficient", () => {
const html = render(<CorrelationCard result={insufficient} />);
expect(html).toContain("Need more data to see this pattern");
expect(html).toContain("Need more data to see this relationship");
expect(html).not.toContain("Try a 7-day experiment");
});
@@ -5,13 +5,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
/**
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
* v1.4.22 A4 update — empty-state cards are dropped instead of rendered.
*
* Acceptance:
* 1. Renders the three hypothesis cards.
* 2. Mounts the correlation-disclaimer footer once at the row level.
* 3. 2-up grid on >= md, single column on < md.
* 4. Below-threshold cards delegate their empty-state to
* `<CorrelationCard>` (smoke-checked via the per-card slot).
* 1. Renders only the hypothesis cards whose `status === "ok"`.
* 2. Mounts the correlation-disclaimer footer once at the row level
* when at least one card renders; the row hides itself entirely
* when zero cards render (no header, no disclaimer).
* 3. 2-up grid on >= md when 2+ cards, full-width single column when
* exactly 1 card renders.
* 4. Below-threshold cards are filtered at the row level so the
* layout collapses cleanly.
*/
vi.mock("next/dynamic", () => ({
@@ -67,7 +71,7 @@ const okWeightWeekday: CorrelationResult = {
};
describe("<CorrelationRow>", () => {
it("renders all three hypothesis cards", () => {
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
const html = render(
<CorrelationRow
results={{
@@ -78,9 +82,9 @@ describe("<CorrelationRow>", () => {
/>,
);
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
expect(matches.length).toBe(3);
expect(matches.length).toBe(2);
expect(html).toMatch(/data-kind="bp-compliance"/);
expect(html).toMatch(/data-kind="mood-pulse"/);
expect(html).not.toMatch(/data-kind="mood-pulse"/);
expect(html).toMatch(/data-kind="weight-weekday"/);
});
@@ -96,10 +100,10 @@ describe("<CorrelationRow>", () => {
);
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
expect(matches.length).toBe(1);
expect(html).toContain("Patterns are observational, not causal");
expect(html).toContain("Relationships are observational, not causal");
});
it("applies the 2-up grid on >= md and single column on < md", () => {
it("applies the 2-up grid on >= md when 2+ ok cards render", () => {
const html = render(
<CorrelationRow
results={{
@@ -113,6 +117,36 @@ describe("<CorrelationRow>", () => {
expect(html).toMatch(/md:grid-cols-2/);
});
it("collapses to a full-width single column when exactly 1 ok card renders", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toMatch(/grid-cols-1/);
// The 2-up rule must NOT activate when only one card has data —
// a single card should span 100 % width on its own row instead of
// half-width with empty space next to it.
expect(html).not.toMatch(/md:grid-cols-2/);
});
it("hides the row entirely (no header, no disclaimer) when zero cards have data", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: insufficientMoodPulse,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toBe("");
});
it("renders German copy when locale=de", () => {
const html = render(
<CorrelationRow
@@ -124,21 +158,6 @@ describe("<CorrelationRow>", () => {
/>,
"de",
);
expect(html).toContain("Muster");
expect(html).toContain("nicht kausal");
});
it("delegates empty-state to per-card empty-state when below threshold", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: insufficientMoodPulse,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
const matches = html.match(/Need more data to see this pattern/g) ?? [];
expect(matches.length).toBe(3);
});
});
@@ -266,7 +266,7 @@ describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)
});
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
// Reproduces Marc's production /insights crash on 2026-05-10:
// Reproduces the maintainer's production /insights crash on 2026-05-10:
//
// TypeError: Cannot read properties of undefined (reading 'replace')
// at stripChartTokens(insight.summary)
@@ -243,4 +243,36 @@ describe("<RecommendationCard>", () => {
);
expect(html).not.toContain("data-feedback-thumb=");
});
/**
* v1.4.22 A6 — strip chart-tokens from the always-visible
* recommendation text. W1a §3 traced the production "metric:WEIGHT"
* leak the maintainer saw at the bottom of the advisor card to this exact
* render path: every other AI-prose surface ran through
* `stripChartTokens()`, the recommendation text didn't. Pin the
* strip so a future copy-paste regression can't reintroduce the
* leak.
*/
it("strips literal chart tokens (metric:WEIGHT et al) from the rec text", () => {
const recWithToken = {
...recBase,
text: "Increase fluids. metric:WEIGHT",
};
const html = render(<RecommendationCard rec={recWithToken} index={0} />);
expect(html).not.toContain("metric:WEIGHT");
expect(html).toContain("Increase fluids.");
});
it("strips lowercase / mixed-case model output from the rec text", () => {
const recWithLowercaseToken = {
...recBase,
text: "Watch the trend. metric:blood_pressure_sys today",
};
const html = render(
<RecommendationCard rec={recWithLowercaseToken} index={0} />,
);
expect(html).not.toContain("metric:blood_pressure_sys");
expect(html).toContain("Watch the trend.");
expect(html).toContain("today");
});
});
@@ -11,7 +11,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachInput>", () => {
it("mounts the textarea, mic, send button, and disclaimer slots", () => {
it("mounts the textarea, mic, and send button slots", () => {
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
// composer no longer renders its own paragraph below the input.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
@@ -19,8 +21,8 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
expect(html).toContain('data-slot="coach-input-disclaimer"');
expect(html).toContain("Coach replies are generated");
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + hint", () => {
@@ -31,13 +33,12 @@ describe("<CoachInput>", () => {
expect(html).toContain("Press Enter to send");
});
it("renders the German placeholder + disclaimer when locale is 'de'", () => {
it("renders the German placeholder under the 'de' locale", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
"de",
);
expect(html).toContain("Frag mich etwas zu deinen Daten");
expect(html).toContain("Klinische Entscheidungen gehören in die Hand");
});
it("disables the mic button with the v1.5 tooltip text", () => {
@@ -1,7 +1,39 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The user bubble pulls `useAuth().user.gravatarUrl` to mirror the
// Coach avatar (v1.4.22 B3). SSR rendering through `renderToStaticMarkup`
// has no TanStack-Query provider, so we mock the hook the same way
// `recommendation-feedback.test.tsx` does — returning a tester user
// with no Gravatar so we exercise the initials fallback path by
// default, and override per-test where needed.
const useAuthMock = vi.fn<
() => {
user: {
id: string;
username: string;
role: string;
gravatarUrl: string | null;
};
isAuthenticated: boolean;
isLoading: boolean;
}
>(() => ({
user: {
id: "test-user",
username: "tester",
role: "USER",
gravatarUrl: null,
},
isAuthenticated: true,
isLoading: false,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
import { MessageThread } from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
@@ -216,4 +248,139 @@ describe("<MessageThread>", () => {
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
);
});
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
// v1.4.22 — the Coach surfaces load-bearing numbers in a
// collapsible "What I'm looking at" disclosure under the assistant
// bubble. Verify the structure renders correctly and the entries
// show label / value / unit / window.
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg7 systolic",
value: "138",
unit: "mmHg",
window: "last7days",
},
{
label: "avg30 systolic",
value: "134",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain("What I&#x27;m looking at");
expect(html).toContain('data-slot="coach-evidence-list"');
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
expect(rows).toBe(2);
expect(html).toContain("avg7 systolic");
expect(html).toContain("138 mmHg");
expect(html).toContain("(last7days)");
expect(html).toContain("avg30 systolic");
expect(html).toContain("134 mmHg");
// Disclosure is collapsed by default (no `open` attribute).
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
});
it("renders the evidence-block label in German under 'de' locale", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg30 systolisch",
value: "138",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />, "de");
expect(html).toContain("Worauf bezieht sich das?");
});
it("renders entries without unit or window cleanly", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: [],
metrics: ["compliance"],
keyValues: [{ label: "30-day adherence", value: "96" }],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain("30-day adherence");
// No stray empty unit/window markup.
expect(html).not.toMatch(
/30-day adherence:\s*<\/span>\s*<strong[^>]*>\s*</,
);
});
it("hides the disclosure entirely when keyValues is empty or absent", () => {
// baseConversation.messages[1].metricSource has no `keyValues`
// field — the source chips still render but the disclosure does
// not appear at all.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
});
it("renders the user bubble with a Gravatar when one is set (B3 parity)", () => {
// v1.4.22 B3 — the user-bubble avatar uses the same dimensions
// as the Coach avatar and pulls the Gravatar URL from useAuth.
useAuthMock.mockReturnValueOnce({
user: {
id: "u-1",
username: "marc",
role: "USER",
gravatarUrl: "https://www.gravatar.com/avatar/abc123?s=64&d=mp",
},
isAuthenticated: true,
isLoading: false,
});
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(
/<img[^>]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/,
);
expect(html).toMatch(
/data-slot="coach-bubble-user-avatar"[^>]*class="[^"]*size-8/,
);
});
it("falls back to initials when no Gravatar URL is present", () => {
// Default mock returns gravatarUrl: null → initials path.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
});
});
@@ -44,11 +44,10 @@ describe("<SourcesRail>", () => {
expect(html).toContain("Einnahmetreue");
});
it("renders the footer disclaimer", () => {
it("renders the medical disclaimer in the footer (relocated from the composer in v1.4.22)", () => {
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads only the data you&#x27;ve connected.",
);
expect(html).toContain('data-slot="coach-sources-disclaimer"');
expect(html).toContain("Clinical decisions belong with your doctor");
});
it("renders a checkbox per row + a window selector trigger", () => {
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Plus, Settings2, Sparkles } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -11,12 +11,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -178,15 +172,13 @@ export function CoachDrawer({
"flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Title + new-chat button. Settings cog
sits next to the avatar on the LEFT — the previous layout
painted it next to the new-chat button on the right, where
Radix Sheet's default close-X (absolute top-4 right-4)
overlapped the cog and made it visually un-clickable. The
new layout keeps the cog inside the visible header column
and reserves the right edge for the close-X alone.
v1.4.20.1: pr-12 ensures the new-chat button never slides
under the close-X on narrower viewports either. */}
{/* Header (full width). Avatar + title + new-chat button.
v1.4.22 B5 — the settings cog was removed: it had no real
wiring and the matching v1.4.21 placeholder tooltip
("Coach settings arrive in v1.4.21") read as a dead button.
A real settings surface for per-user prompt-tuning lands
with v1.4.23. pr-12 keeps the new-chat button clear of the
Sheet's close-X on narrower viewports. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-3 border-b p-3 pr-12 sm:p-4 sm:pr-14"
@@ -197,29 +189,6 @@ export function CoachDrawer({
>
<Sparkles className="text-background size-4" />
</div>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
// Match the visible disabled state — sighted users
// see a coming-soon tooltip; SR users hear the same
// copy instead of a working "Coach settings" label.
aria-label={t("insights.coach.settingsTooltip")}
data-slot="coach-drawer-settings"
className="size-8 shrink-0"
>
<Settings2 className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.settingsTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-sm font-semibold">
{drawerTitle}
@@ -30,8 +30,9 @@ import { useTranslations } from "@/lib/i18n/context";
* with the iOS app in v1.5") because the natural shipper of voice is
* the native client, not the PWA.
*
* The disclaimer below the input is a hard requirement — Coach
* replies are not medical advice.
* v1.4.22 B4: the disclaimer ("Coach replies are generated …") moved
* out of the composer and into the sources rail footer, so the
* composer stays focused on the input affordance.
*/
export interface CoachInputProps {
value: string;
@@ -55,37 +56,31 @@ export function CoachInput({
}: CoachInputProps) {
const { t } = useTranslations();
const canSubmit = !disabled && value.trim().length > 0;
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter") return;
// Shift+Enter inserts a newline.
if (event.shiftKey) return;
// Plain Enter and ⌘/Ctrl+Enter both submit.
// Shift+Enter inserts a newline; plain Enter and ⌘/Ctrl+Enter submit.
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
if (!disabled && value.trim().length > 0) {
onSubmit();
}
if (canSubmit) onSubmit();
},
[disabled, onSubmit, value],
[canSubmit, onSubmit],
);
const handleFormSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!disabled && value.trim().length > 0) {
onSubmit();
}
if (canSubmit) onSubmit();
},
[disabled, onSubmit, value],
[canSubmit, onSubmit],
);
const canSubmit = !disabled && value.trim().length > 0;
return (
<form
data-slot="coach-input"
onSubmit={handleFormSubmit}
className="flex flex-col gap-2"
className="flex flex-col"
>
<div
className={cn(
@@ -151,12 +146,6 @@ export function CoachInput({
</Button>
</div>
</div>
<p
data-slot="coach-input-disclaimer"
className="text-muted-foreground text-[11px]"
>
{t("insights.coach.composerDisclaimer")}
</p>
</form>
);
}
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useRef } from "react";
import { Bot, Sparkles, User } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { SourceChips } from "./source-chips";
import type {
@@ -62,7 +63,33 @@ export function MessageThread({
// persisted twin lands — comparing on `messageId` keeps the
// transition seamless while never accidentally hiding an in-flight
// bubble (during streaming `messageId` is null).
// v1.4.22 W5 reconcile (Code-MED-3) — streaming/persisted-twin
// race. On slow connections SSE `done` fires before the
// invalidate-refetch resolves; the persisted twin lands while the
// streaming bubble is still painted, producing a 200-500ms
// duplicate render. Hide the persisted twin (matched on
// streaming.messageId) for a 150ms grace window after it first
// appears so the streaming bubble stays alone until the streaming
// state cleans up naturally on the next `send`.
const [graceWindow, setGraceWindow] = useState(false);
const lastPersistedIdRef = useRef<string | null>(null);
useEffect(() => {
const persistedId = streaming?.messageId ?? null;
if (
persistedId &&
persistedId !== lastPersistedIdRef.current &&
messages.some((m) => m.id === persistedId)
) {
lastPersistedIdRef.current = persistedId;
setGraceWindow(true);
const handle = setTimeout(() => setGraceWindow(false), 150);
return () => clearTimeout(handle);
}
}, [streaming?.messageId, messages]);
const suppressedTwinId = graceWindow ? streaming?.messageId : null;
const streamingPersisted =
!graceWindow &&
streaming?.messageId != null &&
messages.some((m) => m.id === streaming.messageId);
const streamingActive =
@@ -119,15 +146,21 @@ export function MessageThread({
"scroll-smooth",
)}
>
{messages.map((m) => (
<ChatBubble
key={m.id}
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
/>
))}
{messages.map((m) => {
// v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
if (m.id === suppressedTwinId) return null;
return (
<ChatBubble
key={m.id}
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
/>
);
})}
{streamingActive && streaming && (
// role=log + aria-live=polite so screen-reader users hear the
// assistant prose announce as tokens land. aria-relevant=text
@@ -144,6 +177,21 @@ export function MessageThread({
/>
</div>
)}
{/* v1.4.22 W5 reconcile (Design-H3) — medical-disclaimer
reach regression. v1.4.22 B4 moved the disclaimer to the
sources-rail footer, but the rail is xl-only; on mobile
and laptop viewports the disclaimer was only reachable
via the chevron tray. Pin a small persistent line at the
bottom of the message thread so every Coach session
carries the disclaimer regardless of viewport. The rail
footer stays for desktop users — the redundancy is
intentional for clinical-adjacent UI. */}
<p
data-slot="coach-thread-disclaimer"
className="text-muted-foreground mt-auto pt-2 text-[10px] leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
</div>
);
}
@@ -166,7 +214,16 @@ function ChatBubble({
errorCode,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
if (role === "user") {
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
// avatar matches the Coach avatar in size and visual weight.
// Falls back to the generic person glyph when the user hasn't
// configured an email (or the Gravatar lookup returned null).
const gravatarUrl = user?.gravatarUrl ?? null;
const initials = user?.username
? user.username.slice(0, 2).toUpperCase()
: null;
return (
<div
data-slot="coach-bubble-user"
@@ -181,12 +238,24 @@ function ChatBubble({
>
{content}
</div>
<div
aria-hidden="true"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full"
>
<User className="size-3.5" />
</div>
{gravatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={gravatarUrl}
alt=""
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="border-border/50 mt-0.5 size-8 shrink-0 rounded-full border object-cover"
/>
) : (
<div
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"
>
{initials ?? <User className="size-3.5" />}
</div>
)}
</div>
);
}
@@ -202,6 +271,8 @@ function ChatBubble({
? t("insights.coach.errorProvider")
: null;
const keyValues = metricSource?.keyValues ?? [];
return (
<div
data-slot="coach-bubble-assistant"
@@ -232,6 +303,55 @@ function ChatBubble({
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
)}
{metricSource && <SourceChips provenance={metricSource} />}
{keyValues.length > 0 && (
<details
data-slot="coach-evidence"
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
)}
>
<summary
data-slot="coach-evidence-summary"
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<ul
data-slot="coach-evidence-list"
className="text-foreground mt-2 flex flex-col gap-1"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
<span className="text-muted-foreground">{kv.label}:</span>{" "}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
</details>
)}
</div>
</div>
);
@@ -275,13 +275,21 @@ export function SourcesRail({
);
})}
</ul>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
{/* v1.4.22 B4: the rail's footer now carries the medical
disclaimer (relocated from below the composer). The
source-picker section above stands on its own as the rail's
primary content — users see the disclaimer once, in a calm
place, instead of every time they look at the input. */}
<div
data-slot="coach-sources-disclaimer"
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
>
<Activity
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.sourcesFooter")}
{t("insights.coach.composerDisclaimer")}
</p>
</div>
</div>
+31 -11
View File
@@ -1,5 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { CorrelationCard } from "./correlation-card";
@@ -7,14 +8,18 @@ import { CorrelationCard } from "./correlation-card";
/**
* v1.4.20 phase B3 — Correlation row.
*
* Renders the three pre-defined hypothesis cards in a 2-up grid on
* `>=md` and a single column on `<md`. A single correlation-disclaimer
* footer sits below the row — the disclaimer applies to every card so
* we don't repeat it per-card.
* Renders the pre-defined hypothesis cards in a 2-up grid on `>=md` and
* a single column on `<md`. A single correlation-disclaimer footer sits
* below the row — the disclaimer applies to every card so we don't
* repeat it per-card.
*
* The `<CorrelationCard>` itself owns the empty-state for below-
* threshold results, so this row never has to filter — it always
* mounts all three cards regardless of `status`.
* v1.4.22 A4 — empty-state cards are dropped instead of rendered. Any
* hypothesis whose `status !== "ok"` is filtered out so the layout
* collapses cleanly: 2 ok cards → 50/50, 1 ok card → 100 %, 0 ok cards
* → the row hides itself entirely (no header, no disclaimer). Up to
* v1.4.21 the `<CorrelationCard>` painted an EmptyState for below-
* threshold results, which left half-rows of greyed-out placeholders
* on a sparse account.
*/
interface CorrelationRowProps {
@@ -28,6 +33,16 @@ interface CorrelationRowProps {
export function CorrelationRow({ results }: CorrelationRowProps) {
const { t } = useTranslations();
// Drop insufficient-data tiles up-front. The grid below picks 1-col vs
// 2-col automatically so 1 ok card spans 100 % width on its own row.
const okResults = [
results.bpCompliance,
results.moodPulse,
results.weightWeekday,
].filter((r) => r.status === "ok");
if (okResults.length === 0) return null;
return (
<section
data-slot="correlation-row"
@@ -42,10 +57,15 @@ export function CorrelationRow({ results }: CorrelationRowProps) {
{t("insights.correlationRow.subtitle")}
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CorrelationCard result={results.bpCompliance} />
<CorrelationCard result={results.moodPulse} />
<CorrelationCard result={results.weightWeekday} />
<div
className={cn(
"grid grid-cols-1 gap-4",
okResults.length > 1 && "md:grid-cols-2",
)}
>
{okResults.map((result) => (
<CorrelationCard key={result.kind} result={result} />
))}
</div>
<p
data-slot="correlation-row-disclaimer"
+33 -25
View File
@@ -238,33 +238,41 @@ export function HeroStrip({
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>
{(() => {
// v1.4.22 W5 reconcile (S-02) — collapse the
// enabled/disabled twin Buttons (variant, size, slot,
// class, icon, label all identical) into a single
// shared structure with the Link wrapping conditionally.
const weeklyLabel = (
<>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Button>
)}
</>
);
return weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
{weeklyLabel}
</Button>
);
})()}
{/* B2b wires this into the Coach drawer. The button is
enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet
@@ -11,6 +11,7 @@ import type {
import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { Locale } from "@/lib/i18n/config";
import {
RecommendationFeedback,
@@ -333,7 +334,9 @@ export function RecommendationCard({
{norm.severity}
</Badge>
)}
<p className="text-sm leading-snug">{norm.text}</p>
<p className="text-sm leading-snug">
{stripChartTokens(norm.text)}
</p>
</div>
</div>
</div>
+24 -15
View File
@@ -26,17 +26,20 @@ import {
* the row's visual rhythm.
*/
// v1.4.22 W5 reconcile (S-04) — both `dynamic()` calls used the same
// pulse-skeleton placeholder. Hoist so the loading affordance stays
// in lock-step (cleared against `feedback_charts_visual_identity.md`
// — placeholder only, no chart visual change).
const ChartSkeleton = () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
);
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
),
},
{ ssr: false, loading: ChartSkeleton },
);
const MoodChart = dynamic(
@@ -44,12 +47,7 @@ const MoodChart = dynamic(
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
),
},
{ ssr: false, loading: ChartSkeleton },
);
interface TrendsRowProps {
@@ -95,8 +93,19 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
{t("insights.trendsRow.subtitle")}
</p>
</div>
{/* v1.4.22 A4 — equal-height cards. The AI annotation prose
below each chart varies in length, which used to leave the
three cards on visibly different baselines. Each card is now
a flex column with `min-h-[300px]` so the chart anchors to
the top and the annotation grows downward into a shared
minimum height. The grid + min-h together preserve the visual
rhythm even on a row where only one annotation is multi-line. */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div data-slot="trends-row-card" data-metric="bp" className="space-y-2">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
@@ -114,7 +123,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="weight"
className="space-y-2"
className="flex min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["WEIGHT"]}
@@ -132,7 +141,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="mood"
className="space-y-2"
className="flex min-h-[300px] flex-col gap-2"
>
<MoodChart title={t("charts.mood")} mini />
<TrendAnnotation
@@ -85,7 +85,7 @@ describe("<SidebarNav> bug-report toggle", () => {
});
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
// v1.4.16 A1: Marc reported the global sidebar expanding admin
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
// already provides per-section nav inside the page. The Admin entry
// must behave EXACTLY like the Settings entry: a single link with no
@@ -112,7 +112,7 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
expect(html).toContain('href="/admin"');
// Sub-section links must NOT appear in the global sidebar — they
// belong to `<AdminShell>`'s in-page nav. Marc reported the
// belong to `<AdminShell>`'s in-page nav. the maintainer reported the
// expansion as broken UX in v1.4.16; this guard keeps it gone.
for (const section of ADMIN_SECTIONS) {
expect(html).not.toContain(`href="/admin/${section.slug}"`);
+6 -20
View File
@@ -41,26 +41,12 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
}
}, [isLoading, isAuthenticated, isAdminPage, user?.role, router]);
// Redirect to onboarding if not completed
useEffect(() => {
if (
!isLoading &&
isAuthenticated &&
!isOnboardingPage &&
!isPublicPage &&
user &&
!user.onboardingCompletedAt
) {
router.replace("/onboarding");
}
}, [
isLoading,
isAuthenticated,
isOnboardingPage,
isPublicPage,
user,
router,
]);
// v1.4.22 C4 — onboarding redirect moved to proxy.ts so it lands on
// the first server response instead of post-hydration. The proxy
// reads the `hl_onboarding` cookie (mirrored from the DB by the auth
// routes) and 307s to /onboarding before this shell ever paints. The
// previous `useEffect`-based redirect caused a brief dashboard flash
// for users with `onboardingCompletedAt === null`.
// Loading state
if (isLoading) {
+1 -1
View File
@@ -419,7 +419,7 @@ export function OnboardingTour({
{/* v1.4.15 H4 design: tour footer buttons reach the WCAG 2.5.5
44 px tap-target floor on mobile. The `size="sm"` default
of `h-8` clipped to ~32 px which Marc reported as too small
of `h-8` clipped to ~32 px which the maintainer reported as too small
on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
keeps the desktop visual close enough — the buttons grow
~4 px taller — while making mobile usable. */}
@@ -1,7 +1,7 @@
/**
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
*
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
* The new section replaces the v1.4.15-vintage top/bottom split (the maintainer's
* `feedback_settings_no_split.md`): a single Select picks the active
* provider; the form below is switch-rendered for that provider; the
* fallback chain at the bottom reorders / toggles entries with arrow
@@ -2,7 +2,7 @@
* v1.4.19 phase A5 — IntegrationStatusPill
*
* The single tag rendered top-right of every integration card. The
* pill is the ONLY place a connection status surfaces — Marc was
* pill is the ONLY place a connection status surfaces — the maintainer was
* staring at three- to four-fold redundancy across Withings + Mood Log
* cards in v1.4.18 and called it out. This component is reusable so
* v1.4.20 can drop the same pill on the Apple Health card.
@@ -1,7 +1,7 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
*
* Marc's verbatim concern (paraphrased):
* the maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
@@ -22,7 +22,7 @@
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. Mood Log card has the visual divider that Withings always had
* — consistency Marc explicitly called out.
* — consistency the maintainer explicitly called out.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -219,7 +219,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last attempt");
});
it("Withings reauth state surfaces 'Error — reconnect' on the pill (Marc's wording)", () => {
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
+1 -1
View File
@@ -266,7 +266,7 @@ export function AboutSection() {
that proxies through `/api/version/check-updates`. The v1.4.2
version called `api.github.com` directly from the browser, which
the production CSP blocked silently — that's why "nothing
happened" when Marc clicked the button. */}
happened" when the user clicked the button. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
+2 -2
View File
@@ -168,7 +168,7 @@ export function AiSection() {
/**
* The single dropdown-driven configuration card. Replaces the
* v1.4.15-vintage two-card layout (Codex connect at the top, personal
* provider form at the bottom) per Marc's
* provider form at the bottom) per the maintainer's
* `feedback_settings_no_split.md`: one provider Select, one matching
* form below, one fallback-chain card at the bottom.
*
@@ -389,7 +389,7 @@ function ActiveProviderSelect({
/**
* Switch-rendered provider configuration. The component is the
* "form below adapts dynamically" half of Marc's pulldown contract:
* "form below adapts dynamically" half of the maintainer's pulldown contract:
* pick Codex → see Codex form; pick OpenAI → see API-key + model
* dropdown + base-URL override. The card frame is the same in every
* branch so transitions feel like updating a single concept rather
@@ -13,6 +13,7 @@ import {
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
type DashboardLayout,
type DashboardWidgetId,
@@ -54,7 +55,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const queryClient = useQueryClient();
const { data: remote, isLoading } = useQuery({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("failed");
@@ -81,7 +82,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
return (await res.json()).data as DashboardLayout;
},
onSuccess: (saved) => {
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
setDraft(null);
toast.success(t("dashboard.layoutSaveSuccess"));
},
@@ -95,7 +96,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
return (await res.json()).data as DashboardLayout;
},
onSuccess: (saved) => {
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
setDraft(null);
toast.success(t("dashboard.layoutResetSuccess"));
},
@@ -114,7 +115,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
/**
* v1.4.15 Fix 5 — independent toggle for the *strip tile* (the upper
* row of trend cards). Until v1.4.14 a single switch controlled both
* the tile AND the chart for the same metric, which Marc found too
* the tile AND the chart for the same metric, which the maintainer found too
* coarse: he wanted a chart visible without the tile (for metrics he
* tracks without wanting the at-a-glance number) or vice versa.
*/
@@ -181,9 +182,17 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{t("dashboard.layoutReset")}
</Button>
</div>
<p className="text-muted-foreground text-sm">
{t("dashboard.customizeSubtitle")}
</p>
{/*
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
already renders a `settings.sections.dashboard.description`
paragraph beneath the H1. The previous in-card help line
repeated the same idea ("Choose which cards appear …
Defaults work out of the box.") right next to the comparison
picker, giving the page three muted-foreground help blocks
stacked on top of each other. Removing this duplicate keeps
the comparison picker as the only thing between the header
and the widget table.
*/}
{/* v1.4.16 phase B8 — comparison baseline picker. Lives at the top
of the section because it changes how every chart + tile below
@@ -242,8 +251,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.15 Fix 5 — table-style header naming the two
switches. The "tile" column controls the strip tile in
the upper row; the "chart" column controls the line
chart in the lower row. Marc wanted independent control
of the two surfaces (memory feedback_dashboard_top_tiles
chart in the lower row. the maintainer wanted independent control
of the two surfaces (per feedback_dashboard_top_tiles
_selectable.md). */}
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-5" aria-hidden="true" />
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
/**
* v1.4.19 phase A5 — single status pill rendered top-right of every
* integration card (Withings, Mood Log, Apple Health…). It replaces
* the three- to four-fold redundant status surface Marc spotted in
* the three- to four-fold redundant status surface the maintainer spotted in
* v1.4.18:
*
* - top-right "Connected" badge
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* - bottom-of-card "letzter Sync" line
*
* Now there is exactly ONE place a sync status can appear on a card
* and it follows the pattern Marc liked from the Withings header: a
* and it follows the pattern the maintainer liked from the Withings header: a
* Dracula-tokenized chip with the relative time inline.
*
* The component is locale-aware (EN + DE) and mobile-safe: on a
@@ -136,7 +136,7 @@ function pillStateFor(
* sync attempt failed. The pill conveys "something is wrong"; this
* line tells the user *what* is wrong so they can act on it. Keeping
* it deliberately small (one icon + one line) so it doesn't recreate
* the v1.4.18 redundant banner Marc removed.
* the v1.4.18 redundant banner the maintainer removed.
*/
function IntegrationErrorMessage({ message }: { message: string }) {
return (
@@ -653,7 +653,7 @@ function MoodLogCard({
</p>
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(Marc explicitly called the asymmetry out). */}
(the maintainer explicitly called the asymmetry out). */}
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
@@ -721,7 +721,10 @@ function MoodLogCard({
setMsgType("success");
}}
>
{t("common.copied").replace("!", "")}
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
common.copy key instead of stripping the
exclamation off common.copied. */}
{t("common.copy")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
@@ -733,7 +736,7 @@ function MoodLogCard({
{/* v1.4.19 A5 — The "letzter Sync" line that used to
live here is gone. The pill in the card header
already carries that information; repeating it
inside the body is the redundancy Marc flagged. The
inside the body is the redundancy the maintainer flagged. The
entry-count is the only number we still surface
because it's an integration-specific datapoint the
pill cannot convey. */}
@@ -258,7 +258,7 @@ function ChannelRow({
{/* v1.4.15 H4 design: action buttons hit the WCAG 2.5.5 44 px
floor on mobile via `min-h-11`. Settings is the most-touched
mobile surface; `size="sm"` here clips to ~32 px which Marc
mobile surface; `size="sm"` here clips to ~32 px which the maintainer
flagged as too small on the iPad/iPhone PWA shell. */}
<div className="flex flex-wrap gap-2">
{channel.state === "auto_disabled" && (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "../tabs";
describe("<TabsList>", () => {
it("clamps the y-axis so a fixed-height strip never paints a vertical scrollbar", () => {
// Marc reported a stray "mini scroll-button" on the right side of
// the maintainer reported a stray "mini scroll-button" on the right side of
// the feedback inbox tab strip (Open / Acknowledged / Resolved /
// Archived). Root cause: `overflow-x-auto` couples to `overflow-y`
// — once one axis is `auto`, the other can no longer be `visible`,
+1 -1
View File
@@ -42,7 +42,7 @@ function Tabs({
// ~1 px taller than the strip on some glyph stacks, a tiny painted
// vertical scrollbar appeared on the right edge of short strips
// (e.g. the admin feedback `Open / Acknowledged / Resolved /
// Archived` tabs Marc reported in v1.4.19). Locking the y-axis hides
// Archived` tabs the maintainer reported in v1.4.19). Locking the y-axis hides
// that bar without affecting horizontal swipe behaviour.
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
// (`focus-visible:ring-[3px]`) on triggers stays inside the
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more