diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1ce92440c..1b73fccc1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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__:*}" diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 32ad3e08a..35ce1b64f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 diff --git a/.planning/STATE.md b/.planning/STATE.md index 9395ae063..fdd40589a 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 ` 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); `` 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 `` 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/
` 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] `` 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] `` lifted above the hero strip (sticky + scroll-anchored; tabs visible first) -### B1 — Hero strip + Daily Briefing + Suggested-prompts (~3-5 days) +### A6 — Raw `Metrik ` token leak fix -- [x] Replace `` 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] `` 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] `` + `` 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] `` 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 `` 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 + ``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 `` with three-column - layout on `lg+` (history rail · message thread · sources rail) and - full-screen single-column on mobile; `` renders user - / assistant bubbles + an in-flight streaming bubble with - pinned-to-bottom auto-scroll; `` renders provenance as - labels-only chips (metric · window · n-count, never raw values); - `` 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; `` with substring filter - and confirm-then-go delete; `` 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 -`` exposes a different `data-slot` value than the -legacy `` 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; - `` (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?"); `` 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 diff --git a/.planning/coolify-auto-deploy-howto.md b/.planning/coolify-auto-deploy-howto.md new file mode 100644 index 000000000..d35d7022b --- /dev/null +++ b/.planning/coolify-auto-deploy-howto.md @@ -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 :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). diff --git a/.planning/phase-W1a-report.md b/.planning/phase-W1a-report.md new file mode 100644 index 000000000..229d5530a --- /dev/null +++ b/.planning/phase-W1a-report.md @@ -0,0 +1,508 @@ +# Wave 1a — PROD probe + token-leak hunt + backlog inventory (v1.4.22) + +Started: 2026-05-10T20:20+02:00 +Branch: develop (post-v1.4.21) +Production probed: `https://healthlog.bombeck.io` (image digest +`sha256:4e818d44702c…`, v1.4.21) +Probe scripts (read-only, no commits): +- `.planning/v1422-w1a-tokens-probe.mjs` (admin/api-tokens overflow walk) +- `.planning/v1422-w1a-insights-probe.mjs` (Insights `Metrik` / token leak hunt) +- `.planning/v1422-w1a-targets-probe.mjs` (Zielwerte page screenshot) + +## 1. BD-Zielbereich tile + +### What I see today + +`src/components/charts/trend-card.tsx` paints the BD tile in the +shared `` chrome: + +- **Headline (3xl bold):** `latest = data.bpInTargetPct` — routed through + `windows.allTime?.pct` since v1.4.19 A1. For the live tenant this is + ~10.8 % rounded to 11 %. +- **Sub-row (`mt-auto` muted text-xs):** `7d: · 30d: ` — + `data.bpInTargetPct7d / pct30d`. For the live tenant, both 50 %. +- No window label on the headline. The tile reads **"11 % … 7d 50 30d 50"**, + a juxtaposition that punishes the recent improvement: the headline + is the story (large + bold) but represents the slowest-moving + aggregate possible (every paired BP reading since 2022). + +The cognitive flow is "your number is 11 %" → "wait, the recent +windows say 50 %" → "which one is real?". Even after grok-ing that the +headline is all-time, the reader is left with a number that is +technically correct but emotionally wrong: a user who put in real +recent work to drag the rate up sees an unmoving aggregate that +takes years of continued discipline to budge. + +### Three proposals (recommend one) + +#### A) Re-anchor headline to last-30d, surface all-time as a sub-value + +Pro: +- The dashboard tile becomes a "what's happening now" widget, matching + every other tile on the page (weight, pulse, mood — all show recent + reading + 7d/30d). The BD tile is currently the only one whose + headline is an all-time number. +- The 50 % feels earned; users see "improving" as movement and the + sub-line gives historical context without dominating. +- One-line API change: `bpInTargetPct = windows.last30Days?.pct` plus + a new field `bpInTargetPctAllTime` for the sub-row. + +Con: +- Inverts the v1.4.19 A1 fix story. Need a backlog entry to clarify + that the v1.4.19 fix was about *correctness* (the headline was + silently equal to the 30-day) and v1.4.22 is about *framing* (the + all-time aggregate belongs in the small text). +- Cache invalidation on the `analytics` query (existing TanStack + pattern handles this). + +#### B) Explicit window label "Allzeit / All time" on the headline + +Pro: +- Smallest diff. Add a `{t( + "dashboard.bpInTargetAllTimeLabel")}` next to the 11 %. No + data-shape change, no API change. +- Honours the v1.4.19 A1 contract verbatim — the headline still + surfaces the slowest-moving number. + +Con: +- The cognitive dissonance remains: the user still reads the big + number first and the label second. A 50 % → 50 % → 11 % "punishment + reading" persists; the label only confirms why. +- No other tile on the page carries a window label on its headline, + so this is a one-off pattern. + +#### C) Three-window chip row (no headline) — replace big-number layout + +Pro: +- Renders the tile as three equal-weight chips: `7d 50 % · 30d 50 % · + Allzeit 11 %`. No single number dominates; the user picks the + window that matters to them. +- Visually distinct enough to break the "every TrendCard is identical" + uniformity, which signals to the user that BP-in-target is a + conceptually different metric than weight / pulse. + +Con: +- Largest design lift. Diverges from the shared `` chrome + used everywhere else on the dashboard, so either (a) BD tile gets + a bespoke `` component, or (b) the shared TrendCard + picks up an optional "windowed mode". Either is a Wave-2/3 redesign + item, not a Wave-4 polish patch. +- Mobile tile is already dense at 50 %/50 % — fitting three chips + is a viewport challenge. + +### Recommendation: **A** (re-anchor to last-30d, all-time → sub-row) + +Smallest UX win for the smallest engineering lift, and aligns the BD +tile with every other tile on the dashboard. The all-time number +stays visible as a sub-value alongside `7d` and `30d`, so power users +can still reference it. The v1.4.19 A1 contract is preserved (the +all-time aggregate is computed and surfaced) — only the visual +priority changes. The fix is roughly: + +- `src/app/api/analytics/route.ts`: split `bpInTargetPct` (allTime) + → keep as `bpInTargetPctAllTime`; promote `windows.last30Days.pct` + to the new `bpInTargetPct` headline. +- `src/components/charts/trend-card.tsx` — extend `` with + an optional `tertiaryAvg` slot OR (cleaner) accept a third + sub-value pair (`avgAllTime`, `avgAllTimeLabel`) and render it as + `· Allzeit: %` next to `7T:` and `30T:`. Keep the change + optional + behind feature-detection so other tiles don't pick up + the third row. +- Tests: `bp-in-target.test.ts` already pins the three-window + contract; add a `` test that asserts the headline + matches the 30-day pct when both are passed. + +## 2. Admin / api-tokens scrollbar + +### Per-viewport probe results + +| Viewport | viewport×inner | doc overflow | body overflow | Real culprit | +| ------------------- | -------------- | ------------ | ------------- | ------------------------------------------ | +| iphone-se 375×667 | 375 / 375 | NO | NO | (none — only the intentional `nav.no-scrollbar` mobile section strip) | +| pixel5 393×851 | 393 / 393 | NO | NO | (none — same intentional nav) | +| ipad-mini 768×1024 | 768 / 768 | NO | NO | ` ); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index fd83f6cbd..dfbdcfed7 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -191,7 +191,7 @@ export default function NotificationsPage() { - {t("notifications.title")} + {t("notifications.eventColumn")} {activeChannels.map((ch) => ( ; } @@ -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" diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 5a9228d2c..d2f817eb5 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -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 = { // 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 ( + + ); +} + function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { if (trend === "up") { return ; @@ -507,6 +568,31 @@ function TargetCard({ )} + {/* 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 && ( +
+ + {target.deltaVsLastMonth != null && ( +

+ {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, + })} +

+ )} +
+ )} + {isMedicationCompliance && medicationBreakdown.length > 0 && (

@@ -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. diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index d3e1fb5cf..ef549d28f 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server"; * is always wider than a 393 CSS-px (Pixel-5) viewport, so v1.4.18 * phase A2 added the `no-scrollbar` class to the strip — Chromium / * WebKit otherwise paint an always-on horizontal scrollbar at the top - * of every admin page, which Marc kept mis-attributing to the + * of every admin page, which the maintainer kept mis-attributing to the * `/admin/api-tokens` table card right below it. Three previous fix * attempts (v1.4.15 column-hide, v1.4.16 mobile-card-list, then this * one) all targeted the table while the bar lived in the shell. diff --git a/src/components/admin/__tests__/api-token-no-collapse.test.tsx b/src/components/admin/__tests__/api-token-no-collapse.test.tsx index 6c943fa60..f0925514d 100644 --- a/src/components/admin/__tests__/api-token-no-collapse.test.tsx +++ b/src/components/admin/__tests__/api-token-no-collapse.test.tsx @@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server"; * v1.4 shared-admin page where 13 sections lived on one route; in * v1.5 every section has its own dedicated route, so collapsing the * only card on `/admin/api-tokens` hides the entire surface and - * confused Marc more than it helped. + * confused the maintainer more than it helped. */ vi.mock("next/navigation", () => ({ diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx index 6e2cb877d..69aa06562 100644 --- a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx +++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx @@ -94,7 +94,7 @@ describe("ApiTokenOverviewSection — responsive", () => { it("mobile card-list has no overflow-x-auto wrapper", () => { const html = render(); // Mobile cards must let content wrap within the card rather than - // forcing a scroll container — that was the v1.4.15 bug Marc + // forcing a scroll container — that was the v1.4.15 bug the maintainer // re-reported. Confirm there's no overflow-x-auto INSIDE the // mobile

    . const mobileMatch = html.match(/]*md:hidden[^>]*>([\s\S]*?)<\/ul>/); @@ -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" ``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(//); + expect(tableMatch).not.toBeNull(); + const tableMarkup = tableMatch![0]; + // Pick every 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(/]*\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(/]*md:hidden[^>]*>([\s\S]*?)<\/ul>/); diff --git a/src/components/admin/__tests__/api-token-truncate.test.tsx b/src/components/admin/__tests__/api-token-truncate.test.tsx index 3078df936..d01737bcb 100644 --- a/src/components/admin/__tests__/api-token-truncate.test.tsx +++ b/src/components/admin/__tests__/api-token-truncate.test.tsx @@ -4,13 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix. * - * Marc reported a "minimaler Scrollbar" still painting at the + * the maintainer reported a "minimaler Scrollbar" still painting at the * bottom-right of the api-tokens page, even after the v1.4.18 phase * A2 mobile-strip `no-scrollbar` fix. The remaining culprits are * inside the section card itself: long token names, long usernames, * and long permission strings can each push their parent past the * viewport. Cumulatively this leaves the page in an "almost-fits" - * state where the visible scrollbar Marc sees is the page-level main + * state where the visible scrollbar the maintainer sees is the page-level main * scroll picking up a fraction of pixels of horizontal scroll spill * from the children. * diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx index 2b255719d..6d34550c6 100644 --- a/src/components/admin/__tests__/status-card-grid.test.tsx +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * v1.4.15 phase A2: the `/admin` overview no longer renders the - * `StatusCardGrid` (the section-card grid Marc described as redundant + * `StatusCardGrid` (the section-card grid the maintainer described as redundant * with the sidebar nav). The status-card component itself was removed * from the codebase. This test guards the *new* overview composition: * a welcome card, the system snapshot, and the recent-audit preview. diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 04bfc4e0f..cd7fdd7bd 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -153,7 +153,7 @@ export function AdminShell({ active, children }: AdminShellProps) { painted scrollbar at the top of every admin page; the horizontal swipe + keyboard arrow scrolling still work, the bar just doesn't draw. Without this the 13-section strip - would render an always-on scrollbar that Marc kept + would render an always-on scrollbar that the maintainer kept mis-attributing to the api-tokens table below it. */}
- + {/* 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. */} +
{t("admin.medicationReminders")}
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index d2903ec08..7ca2953ac 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -311,7 +311,7 @@ export function UserManagementSection() { - {t("admin.users")} + {t("admin.userName")} {t("admin.userEmail")} diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index c73b1a20f..c95e35f3d 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -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 diff --git a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx index 624336db2..f71f548e5 100644 --- a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx +++ b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx @@ -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 diff --git a/src/components/charts/__tests__/health-chart-polish.test.tsx b/src/components/charts/__tests__/health-chart-polish.test.tsx index 2fa1b2e6d..9d487d263 100644 --- a/src/components/charts/__tests__/health-chart-polish.test.tsx +++ b/src/components/charts/__tests__/health-chart-polish.test.tsx @@ -123,7 +123,7 @@ describe("computePersonalBaseline()", () => { describe(" 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 `` defs and no `chart-gradient` diff --git a/src/components/charts/__tests__/mood-chart-polish.test.tsx b/src/components/charts/__tests__/mood-chart-polish.test.tsx index d259d2403..7ce7b2f1a 100644 --- a/src/components/charts/__tests__/mood-chart-polish.test.tsx +++ b/src/components/charts/__tests__/mood-chart-polish.test.tsx @@ -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(" 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 dots during SSR (the dot callback // only fires after first client render), so the emoji wouldn't // surface in renderToStaticMarkup output anyway. Inspecting the diff --git a/src/components/charts/__tests__/touch-action-guard.test.ts b/src/components/charts/__tests__/touch-action-guard.test.ts index 8cea91f9a..0104bda2b 100644 --- a/src/components/charts/__tests__/touch-action-guard.test.ts +++ b/src/components/charts/__tests__/touch-action-guard.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; * chart in a `` 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 `
` (the one that contains the * ``) must carry the Tailwind `touch-pan-y` diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index 3ec901027..35ff4c0b7 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -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: diff --git a/src/components/charts/__tests__/trend-card-all-time.test.tsx b/src/components/charts/__tests__/trend-card-all-time.test.tsx new file mode 100644 index 000000000..5362d714c --- /dev/null +++ b/src/components/charts/__tests__/trend-card-all-time.test.tsx @@ -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( + {node}, + ); +} + +const baseProps = { + label: "BP in target", + latest: 50, + unit: "%", + avg7: 55, + avg30: 50, + slope30: null, + icon: Target, + directionSentiment: "up-good" as const, +}; + +describe(" avgAllTime sub-value (v1.4.22 A2)", () => { + it("renders the all-time label + value when `avgAllTime` is provided", () => { + const html = render(); + // 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( + , + ); + 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(); + 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(); + 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( + , + ); + expect(html).toContain("text-dracula-green"); + }); + + it("ships the 7-day-trend chip when a delta is provided", () => { + const html = render( + , + ); + // 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( + , + ); + expect(html).toContain('data-slot="tile-compare-delta"'); + expect(html).toContain("+4.0"); + }); +}); diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index 2664322f4..066cb2826 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -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 diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index 1ca74a05d..3c31bc193 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -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 diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2a76abfe1..7cc0806cf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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"). diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 272e3d525..36c87568a 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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 && ( =sm`. */} {compareBaseline !== "none" && compareDelta != null && ( -
+
)} + {/* 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 ` + {t(avgAllTimeLabelKey)}:{" "} + + {avgAllTime !== null ? formatValue(avgAllTime) : "—"} + + + )}
+ {/* v1.4.22 W5 reconcile (Design-H2) — combined secondary row + for `=sm` because the full layout above takes over. */} + {avgAllTime !== undefined && ( +
+ + {t(avgAllTimeLabelKey)}:{" "} + + {avgAllTime !== null ? formatValue(avgAllTime) : "—"} + + + {compareBaseline !== "none" && compareDelta != null && ( + <> + + + {`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t( + compareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )}`} + + + )} +
+ )}
); diff --git a/src/components/insights/__tests__/correlation-card.test.tsx b/src/components/insights/__tests__/correlation-card.test.tsx index 657b48cec..0e0937984 100644 --- a/src/components/insights/__tests__/correlation-card.test.tsx +++ b/src/components/insights/__tests__/correlation-card.test.tsx @@ -88,7 +88,7 @@ describe("", () => { it("renders the empty-state when status=insufficient", () => { const html = render(); - 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"); }); diff --git a/src/components/insights/__tests__/correlation-row.test.tsx b/src/components/insights/__tests__/correlation-row.test.tsx index 4882dab18..9e5559c66 100644 --- a/src/components/insights/__tests__/correlation-row.test.tsx +++ b/src/components/insights/__tests__/correlation-row.test.tsx @@ -5,13 +5,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations"; /** * v1.4.20 phase B3 — `` 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 - * `` (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("", () => { - it("renders all three hypothesis cards", () => { + it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => { const html = render( ", () => { />, ); 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("", () => { ); 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( ", () => { expect(html).toMatch(/md:grid-cols-2/); }); + it("collapses to a full-width single column when exactly 1 ok card renders", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toBe(""); + }); + it("renders German copy when locale=de", () => { const html = render( ", () => { />, "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( - , - ); - const matches = html.match(/Need more data to see this pattern/g) ?? []; - expect(matches.length).toBe(3); - }); }); diff --git a/src/components/insights/__tests__/insight-advisor-card.test.tsx b/src/components/insights/__tests__/insight-advisor-card.test.tsx index b78c02666..4853afe37 100644 --- a/src/components/insights/__tests__/insight-advisor-card.test.tsx +++ b/src/components/insights/__tests__/insight-advisor-card.test.tsx @@ -266,7 +266,7 @@ describe(" — 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) diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index a37ba0b3b..bd8ad333a 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -243,4 +243,36 @@ describe("", () => { ); 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(); + 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( + , + ); + expect(html).not.toContain("metric:blood_pressure_sys"); + expect(html).toContain("Watch the trend."); + expect(html).toContain("today"); + }); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 753e79c75..660d8f3d5 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -11,7 +11,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe("", () => { - 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( {}} onSubmit={() => {}} />, ); @@ -19,8 +21,8 @@ describe("", () => { 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("", () => { 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( {}} 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", () => { diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index cb4c72274..d3d062fde 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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("", () => { "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(); + expect(html).toContain('data-slot="coach-evidence"'); + expect(html).toContain("What I'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(/]*\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(, "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(); + expect(html).toContain("30-day adherence"); + // No stray empty unit/window markup. + expect(html).not.toMatch( + /30-day adherence:\s*<\/span>\s*]*>\s* { + // baseConversation.messages[1].metricSource has no `keyValues` + // field — the source chips still render but the disclosure does + // not appear at all. + const html = render(); + expect(html).not.toContain('data-slot="coach-evidence"'); + expect(html).not.toContain("What I'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(); + expect(html).toContain('data-slot="coach-bubble-user-avatar"'); + expect(html).toMatch( + /]+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(); + expect(html).toContain('data-slot="coach-bubble-user-avatar"'); + expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE", () => { 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(); - expect(html).toContain( - "The Coach reads only the data you'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", () => { diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index beac983fb..f54c8a44f 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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. */}
- - - - - - - {t("insights.coach.settingsTooltip")} - - -
{drawerTitle} diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 205c116af..58791024f 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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) => { - 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) => { 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 (
-

- {t("insights.coach.composerDisclaimer")} -

); } diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index f277213ba..23ec4c227 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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(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) => ( - - ))} + {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 ( + + ); + })} {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({ />
)} + {/* 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. */} +

+ {t("insights.coach.composerDisclaimer")} +

); } @@ -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 (
{content}
- + {gravatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} ); } @@ -202,6 +271,8 @@ function ChatBubble({ ? t("insights.coach.errorProvider") : null; + const keyValues = metricSource?.keyValues ?? []; + return (
{safeError}

)} {metricSource && } + {keyValues.length > 0 && ( +
+ + +
    + {keyValues.map((kv, idx) => ( +
  • + {kv.label}:{" "} + + {kv.value} + {kv.unit ? ` ${kv.unit}` : ""} + + {kv.window && ( + + {" "} + ({kv.window}) + + )} +
  • + ))} +
+
+ )}
); diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index bbfdd7d17..bb84eccfc 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -275,13 +275,21 @@ export function SourcesRail({ ); })} -
+ {/* 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. */} +
diff --git a/src/components/insights/correlation-row.tsx b/src/components/insights/correlation-row.tsx index d651fe255..586a41723 100644 --- a/src/components/insights/correlation-row.tsx +++ b/src/components/insights/correlation-row.tsx @@ -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` and + * a single column on `` 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 `` 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 (
-
- - - +
1 && "md:grid-cols-2", + )} + > + {okResults.map((result) => ( + + ))}

- + {(() => { + // 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 = ( + <>

{norm.text}

+

+ {stripChartTokens(norm.text)} +

diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 02c58cfc6..3e510ccc7 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -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 = () => ( +
+); + const HealthChart = dynamic( () => import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { - ssr: false, - loading: () => ( -
- ), - }, + { 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: () => ( -
- ), - }, + { ssr: false, loading: ChartSkeleton }, ); interface TrendsRowProps { @@ -95,8 +93,19 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) { {t("insights.trendsRow.subtitle")}

+ {/* 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. */}
-
+
bug-report toggle", () => { }); describe(" 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 `` // 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(" 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 ``'s in-page nav. Marc reported the + // belong to ``'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}"`); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 4b1ceefb9..d2419d662 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -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) { diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index a6459f14a..a1b5a8675 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -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. */} diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index c4a6430fd..63fd21cd2 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -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 diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx index 65ae0c3ce..cc85013c4 100644 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -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. diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index e890c18ac..ddd9fdc74 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -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: [ diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 794c9384f..c21c48e4b 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -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 && (
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 6f8c0adb6..8e2a36c09 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -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 diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 92676a60d..35c297105 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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")}
-

- {t("dashboard.customizeSubtitle")} -

+ {/* + v1.4.22 D / F-32 — the surrounding `` 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). */}

@@ -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. */} diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx index 4e567ee31..3090c57e7 100644 --- a/src/components/settings/notification-status-card.tsx +++ b/src/components/settings/notification-status-card.tsx @@ -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. */}

{channel.state === "auto_disabled" && ( diff --git a/src/components/ui/__tests__/tabs.test.tsx b/src/components/ui/__tests__/tabs.test.tsx index 1685e00df..8dce3593a 100644 --- a/src/components/ui/__tests__/tabs.test.tsx +++ b/src/components/ui/__tests__/tabs.test.tsx @@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "../tabs"; describe("", () => { 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`, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 8e48bb1dc..d25ab4a85 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -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 diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8ed95c41f..4ac040c52 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -11,7 +11,7 @@ import { * * Until v1.4.14 the layout schema had a single `visible` flag per * widget that controlled BOTH the upper-row strip tile AND the lower- - * row chart for the same metric. Marc wanted them as independent + * row chart for the same metric. the maintainer wanted them as independent * toggles so a user could keep the chart on for tracking but hide * the tile (or vice versa). v1.4.15 introduces an optional * `tileVisible` field; the resolver mirrors it from `visible` for @@ -120,7 +120,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { /** * v1.4.16 Fix A5 — top-tile selector real-fix. * - * Marc reported the toggles in Settings → Dashboard didn't filter + * the maintainer reported the toggles in Settings → Dashboard didn't filter * the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema * in `src/app/api/dashboard/widgets/route.ts` was missing * `achievements` even though the default layout has it. Every PUT @@ -132,7 +132,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { * `DEFAULT_DASHBOARD_LAYOUT.widgets`, every consumer (settings UI, * dashboard renderer, API schema) must support it. This test pins * the contract by enumerating every widget the default layout - * advertises, so a future addition gets caught here before Marc + * advertises, so a future addition gets caught here before the maintainer * finds it broken in production. */ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { @@ -154,7 +154,7 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { // The API persists `serializeDashboardLayout(parsed.data)` and // reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`. // A bug in either step (missing widget, wrong order normalization) - // would surface here. Marc's regression: the API schema rejected + // would surface here. the maintainer's regression: the API schema rejected // the layout *before* this round-trip even started. const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT); const resolved = resolveDashboardLayout(serialized); @@ -218,7 +218,7 @@ describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => { /** * v1.4.18 — per-chart overlay-prefs persistence. * - * Marc reverted v1.4.16's always-on chart overlays (gradient, baseline, + * the maintainer reverted v1.4.16's always-on chart overlays (gradient, baseline, * target-zone shading) and asked for per-chart switches that persist * per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we * stay migration-free, mirroring the B8 comparisonBaseline pattern. diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index 8eda58777..642f4a7b4 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -104,7 +104,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { }); }); -// v1.4.16 A8a: Marc spotted "Nrnberg" rendered in /admin/login-overview +// v1.4.16 A8a: the maintainer spotted "Nrnberg" rendered in /admin/login-overview // for an audit row that should have read "Nürnberg, DE". The geo source // (ipwho.is) returns valid UTF-8, but `Response.json()` defers its // charset decoding to whatever the server's `Content-Type` header diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 2b9cd4f54..486484205 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -327,4 +327,37 @@ describe("i18n locale file integrity", () => { todos.map((s) => ` ${s}`).join("\n"), ).toEqual([]); }); + + /** + * v1.4.22 A6 — DE locale must not leak English nouns for the + * Health-Score component contract. + * + * The four `componentBp` / `componentWeight` / `componentMood` / + * `componentCompliance` keys are rendered as the four sub-bar + * labels on the Health Score card. Up to v1.4.21 the DE locale + * shipped `componentMood: "Mood"` (an English noun voice-to-text + * rendered as "Mut" on a quick read). Pin the German values so a + * future copy-paste regression can't reintroduce the leak. + */ + it("DE locale renders Health-Score component labels in German", () => { + const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record< + string, + unknown + >; + const flat: [string, string][] = []; + flattenValues(de, "", flat); + const byKey = new Map(flat); + + const EXPECTED: Array<[string, string]> = [ + ["insights.healthScore.componentBp", "Blutdruck"], + ["insights.healthScore.componentWeight", "Gewicht"], + ["insights.healthScore.componentMood", "Stimmung"], + ["insights.healthScore.componentCompliance", "Einnahmetreue"], + ]; + + for (const [key, expectedDe] of EXPECTED) { + const actual = byKey.get(key); + expect(actual, `DE label for ${key}`).toBe(expectedDe); + } + }); }); diff --git a/src/lib/ai/__tests__/citation-enforcement.test.ts b/src/lib/ai/__tests__/citation-enforcement.test.ts index 7b644ced8..301d63447 100644 --- a/src/lib/ai/__tests__/citation-enforcement.test.ts +++ b/src/lib/ai/__tests__/citation-enforcement.test.ts @@ -12,7 +12,7 @@ import { MockAIProvider } from "../mock-client"; /** * Phase C1 — citation-from-data hard requirement. * - * Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf + * the maintainer, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf * medizinische Dinge." ("It must ground on medical facts / user data.") * * Operational meaning: diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index ad718bc56..d6de7e3a7 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -10,7 +10,7 @@ import { /** * Phase C1 — slug-drift defence. * - * Marc, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko + * the maintainer, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko * darf halt immer überhaupt nicht sein." * ("Slug-drift risk must never happen.") * diff --git a/src/lib/ai/__tests__/confidence-wrapper.test.ts b/src/lib/ai/__tests__/confidence-wrapper.test.ts index 1bd56ecfc..c4711335e 100644 --- a/src/lib/ai/__tests__/confidence-wrapper.test.ts +++ b/src/lib/ai/__tests__/confidence-wrapper.test.ts @@ -10,7 +10,7 @@ import { MockAIProvider } from "../mock-client"; * recommendation and overwrites `rec.confidence` with the value * `computeConfidence()` produces. * - * Default-on per research §7 question 1 (Marc-acked). The override + * Default-on per research §7 question 1 (maintainer-acked). The override * happens whether or not the caller supplies a bucket-series-aware * resolver — when the resolver is missing we fall back to inputs * derivable from the parsed payload alone (`metricSource.n`, diff --git a/src/lib/ai/__tests__/daily-briefing-schema.test.ts b/src/lib/ai/__tests__/daily-briefing-schema.test.ts index bd390192d..7fda27da2 100644 --- a/src/lib/ai/__tests__/daily-briefing-schema.test.ts +++ b/src/lib/ai/__tests__/daily-briefing-schema.test.ts @@ -70,8 +70,8 @@ const baseResponse: AIInsightResponse = { }; describe("PROMPT_VERSION", () => { - it("matches the v1.4.20 series 4.20.x", () => { - expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/); + it("stays on the 4.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/); }); }); diff --git a/src/lib/ai/__tests__/legacy-payload-detection.test.ts b/src/lib/ai/__tests__/legacy-payload-detection.test.ts index 9f5a3b01a..9e183c3f8 100644 --- a/src/lib/ai/__tests__/legacy-payload-detection.test.ts +++ b/src/lib/ai/__tests__/legacy-payload-detection.test.ts @@ -128,8 +128,8 @@ describe("isLegacyInsightPayload()", () => { expect(isLegacyInsightPayload({ summary: "x" })).toBe(false); }); - it("returns true for the v1.4.14 pre-strict-schema shape (Marc's prod blob)", () => { - // The actual cached blob that crashed /insights for Marc on + it("returns true for the v1.4.14 pre-strict-schema shape (the maintainer's prod blob)", () => { + // The actual cached blob that crashed /insights for the live tenant on // 2026-05-10. No `summary`, no `recommendations[]` — just the // pre-v1.4.16 `{changed, stable, drivers, nextSteps, confidence, // limitations}` shape. The route's `safeParse` failed and fell diff --git a/src/lib/ai/__tests__/no-default-positivity-opener.test.ts b/src/lib/ai/__tests__/no-default-positivity-opener.test.ts index 81d18a048..e590443b3 100644 --- a/src/lib/ai/__tests__/no-default-positivity-opener.test.ts +++ b/src/lib/ai/__tests__/no-default-positivity-opener.test.ts @@ -7,7 +7,7 @@ import { /** * v1.4.19 phase A4 — strip the default-positivity opener. * - * Marc reported every insight summary started with the same + * the maintainer reported every insight summary started with the same * "Datengrundlage ist sehr stark" / "Your data foundation is strong" * filler sentence. The user does not see what fields the snapshot * carries, so this opener reads as awkward filler. diff --git a/src/lib/ai/__tests__/trend-annotations-schema.test.ts b/src/lib/ai/__tests__/trend-annotations-schema.test.ts index 30e881dd7..9afb4efb5 100644 --- a/src/lib/ai/__tests__/trend-annotations-schema.test.ts +++ b/src/lib/ai/__tests__/trend-annotations-schema.test.ts @@ -30,8 +30,8 @@ const baseResponse: AIInsightResponse = { }; describe("PROMPT_VERSION (B3 bump)", () => { - it("is on the 4.20.x train", () => { - expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/); + it("stays on the 4.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/); }); it("is bumped past 4.20.0 to signal the trendAnnotations format change", () => { diff --git a/src/lib/ai/__tests__/weekly-report-schema.test.ts b/src/lib/ai/__tests__/weekly-report-schema.test.ts index d5d83bc38..486863eba 100644 --- a/src/lib/ai/__tests__/weekly-report-schema.test.ts +++ b/src/lib/ai/__tests__/weekly-report-schema.test.ts @@ -36,12 +36,13 @@ const baseResponse: AIInsightResponse = { }; describe("PROMPT_VERSION (B4 bump)", () => { - it("is on the 4.20.x train", () => { - expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/); + it("stays on the 4.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.\d+\.\d+$/); }); it("is bumped past 4.20.1 to signal weeklyReport + storyboardAnnotations", () => { - expect(PROMPT_VERSION).toBe("4.20.2"); + expect(PROMPT_VERSION).not.toBe("4.20.0"); + expect(PROMPT_VERSION).not.toBe("4.20.1"); }); }); diff --git a/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts b/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts new file mode 100644 index 000000000..41f27f7a5 --- /dev/null +++ b/src/lib/ai/coach/__tests__/keyvalues-parse.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { parseKeyValueLine, parseKeyValuesSentinel } from "../keyvalues"; + +/** + * v1.4.22 Wave 3 B1+B2 — Coach evidence-block sentinel parser. + */ +describe("parseKeyValueLine", () => { + it("parses a full line with unit + window", () => { + expect( + parseKeyValueLine("avg30 systolic: 138 [mmHg] (last30days)"), + ).toEqual({ + label: "avg30 systolic", + value: "138", + unit: "mmHg", + window: "last30days", + }); + }); + + it("parses a line with unit but no window (day-level pin)", () => { + expect(parseKeyValueLine("Tue 6 May: 142/88 [mmHg]")).toEqual({ + label: "Tue 6 May", + value: "142/88", + unit: "mmHg", + }); + }); + + it("parses a line with neither unit nor window", () => { + expect(parseKeyValueLine("30-day adherence: 96")).toEqual({ + label: "30-day adherence", + value: "96", + }); + }); + + it("tolerates extra whitespace around tokens", () => { + expect( + parseKeyValueLine(" avg30 systolic : 138 [mmHg] (last30days) "), + ).toEqual({ + label: "avg30 systolic", + value: "138", + unit: "mmHg", + window: "last30days", + }); + }); + + it("returns null for an empty line", () => { + expect(parseKeyValueLine("")).toBeNull(); + expect(parseKeyValueLine(" ")).toBeNull(); + }); + + it("returns null for a line with no colon", () => { + expect(parseKeyValueLine("no separator here")).toBeNull(); + }); + + it("returns null when the label is empty", () => { + expect(parseKeyValueLine(": 138")).toBeNull(); + }); + + it("returns null when the value would be empty", () => { + expect(parseKeyValueLine("label only:")).toBeNull(); + }); +}); + +describe("parseKeyValuesSentinel", () => { + it("strips the sentinel block out of the prose", () => { + const raw = [ + "Your last week sits a touch higher than your usual run.", + "", + "---KEYVALUES---", + "avg7 systolic: 138 [mmHg] (last7days)", + "avg30 systolic: 134 [mmHg] (last30days)", + "---END---", + ].join("\n"); + + const out = parseKeyValuesSentinel(raw); + expect(out.prose).toBe( + "Your last week sits a touch higher than your usual run.", + ); + expect(out.keyValues).toEqual([ + { + label: "avg7 systolic", + value: "138", + unit: "mmHg", + window: "last7days", + }, + { + label: "avg30 systolic", + value: "134", + unit: "mmHg", + window: "last30days", + }, + ]); + expect(out.malformed).toBe(false); + }); + + it("returns prose untouched when no sentinel is present", () => { + const out = parseKeyValuesSentinel( + "Just a qualitative reply with no numbers cited.", + ); + expect(out.prose).toBe("Just a qualitative reply with no numbers cited."); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(false); + }); + + it("graceful-degrades when the closing ---END--- is missing", () => { + const raw = [ + "Prose still streams cleanly.", + "", + "---KEYVALUES---", + "avg7 systolic: 138 [mmHg] (last7days)", + ].join("\n"); + const out = parseKeyValuesSentinel(raw); + // Prose stripped, body parsed, but flagged malformed so the route + // can log a wide-event for ops visibility. + expect(out.prose).toBe("Prose still streams cleanly."); + expect(out.keyValues.length).toBe(1); + expect(out.malformed).toBe(true); + }); + + it("clips the block to 8 entries (prompt-contract cap)", () => { + const lines = Array.from( + { length: 12 }, + (_, i) => `metric-${i}: ${100 + i} [mmHg] (last30days)`, + ); + const raw = ["---KEYVALUES---", ...lines, "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBe(8); + expect(out.keyValues[0].label).toBe("metric-0"); + expect(out.keyValues[7].label).toBe("metric-7"); + }); + + it("clips an oversized block at the 1 KB cap", () => { + // 1 200 bytes of label/value pairs — every line passes the regex, + // but the parser trims to 1 024 bytes before splitting so we end + // up with whichever rows fit. + const padded = Array.from( + { length: 60 }, + (_, i) => `label-${i}: ${i.toString().padEnd(15, "x")}`, + ); + const raw = ["---KEYVALUES---", ...padded, "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBeLessThanOrEqual(8); + expect(out.malformed).toBe(true); + }); + + it("silently drops malformed lines mixed in with valid ones", () => { + const raw = [ + "Prose.", + "", + "---KEYVALUES---", + "avg30 systolic: 138 [mmHg] (last30days)", + "not a valid line", + ":no-label-here", + "avg30 mood: 4.1 [/5] (last30days)", + "---END---", + ].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues.length).toBe(2); + expect(out.keyValues.map((k) => k.label)).toEqual([ + "avg30 systolic", + "avg30 mood", + ]); + }); + + it("returns malformed when the sentinel block is empty", () => { + const raw = ["Prose.", "---KEYVALUES---", "---END---"].join("\n"); + const out = parseKeyValuesSentinel(raw); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(true); + expect(out.prose).toBe("Prose."); + }); + + it("returns prose and an empty array for empty input", () => { + const out = parseKeyValuesSentinel(""); + expect(out.prose).toBe(""); + expect(out.keyValues).toEqual([]); + expect(out.malformed).toBe(false); + }); +}); diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts new file mode 100644 index 000000000..5d1d96c7c --- /dev/null +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; +import { getCoachSystemPrompt } from "../system-prompt"; + +/** + * v1.4.22 Wave 3 B1 — pins the Coach system prompt rewrite. + * + * Asserts that: + * 1. PROMPT_VERSION sits on the v1.4.22 train. + * 2. The EN + DE prompts both carry the load-bearing sections — + * GROUND RULES, DAY-LEVEL READINGS / TAGES-LEVEL-MESSWERTE, and + * the EVIDENCE BLOCK / EVIDENZ-BLOCK contract. + * 3. The few-shot tone-calibration `` pairs are baked into + * the prompt body (per the W1b research output). + */ +describe("PROMPT_VERSION (v1.4.22 Coach rewrite)", () => { + it("matches the 4.22.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.22\.\d+$/); + }); +}); + +describe("getCoachSystemPrompt — EN", () => { + const prompt = getCoachSystemPrompt("en"); + + it("embeds the active PROMPT_VERSION", () => { + expect(prompt).toContain(PROMPT_VERSION); + }); + + it("opens with the warm, role-bounded persona statement", () => { + expect(prompt).toMatch(/You are the HealthLog Coach\./); + expect(prompt).toMatch(/sit alongside/i); + expect(prompt).toMatch(/warm,\s+curious,\s+and conservative/i); + expect(prompt).toMatch(/not their doctor/i); + expect(prompt).toMatch(/don't diagnose, prescribe, or change medication/i); + }); + + it("carries the numbered GROUND RULES section", () => { + expect(prompt).toMatch(/GROUND RULES/); + expect(prompt).toMatch(/1\. Prose-first\./); + expect(prompt).toMatch(/2\. Values belong in the evidence block\./); + expect(prompt).toMatch( + /3\. Missing data is an invitation, not a refusal\./, + ); + expect(prompt).toMatch(/4\. Conservative phrasing\./); + expect(prompt).toMatch(/5\. Motivational-interviewing micro-moves\./); + expect(prompt).toMatch(/6\. Redirect off-topic input gracefully\./); + expect(prompt).toMatch(/7\. Ground every number in the SNAPSHOT\./); + }); + + it("preserves the v1.4.21 DAY-LEVEL READINGS section verbatim", () => { + expect(prompt).toMatch(/DAY-LEVEL READINGS — USE THE TIMELINE/); + expect(prompt).toMatch(/timeline\.recent/); + expect(prompt).toMatch(/timeline\.weekly/); + expect(prompt).toMatch(/14 days/); + }); + + it("teaches the ---KEYVALUES--- / ---END--- evidence-block contract", () => { + expect(prompt).toMatch(/EVIDENCE BLOCK/); + expect(prompt).toContain("---KEYVALUES---"); + expect(prompt).toContain("---END---"); + expect(prompt).toMatch(/Hard cap 8 lines/); + expect(prompt).toMatch(/Omit the entire block/); + }); + + it("includes the tone-calibration few-shots", () => { + const exampleOpens = (prompt.match(//g) ?? []).length; + const exampleCloses = (prompt.match(/<\/example>/g) ?? []).length; + // The W1b research called for 3-5 pairs; the production prompt ships 5. + expect(exampleOpens).toBeGreaterThanOrEqual(3); + expect(exampleOpens).toBeLessThanOrEqual(8); + expect(exampleOpens).toBe(exampleCloses); + // At least one example demonstrates the missing-data invitation + // pivot (rule 3) and one demonstrates the off-topic redirect (rule 6). + expect(prompt).toMatch(/I don't see exercise in what you're tracking/); + expect(prompt).toMatch(/outside what I can help with/i); + }); + + it("does not bake live-tenant figures into the prompt body", () => { + // PII rule: the prompt teaches the model how to handle data; it + // must not encode a specific tenant's readings. Sample figures + // inside the few-shot examples are fine (they're generic + // illustrations) but no live username / email / real PII should + // appear. + expect(prompt).not.toMatch(/@.+\.(com|net|org|de|test)/); + }); +}); + +describe("getCoachSystemPrompt — DE", () => { + const prompt = getCoachSystemPrompt("de"); + + it("embeds the active PROMPT_VERSION", () => { + expect(prompt).toContain(PROMPT_VERSION); + }); + + it("opens with the warm, role-bounded persona statement in German", () => { + expect(prompt).toMatch(/Du bist der HealthLog-Coach\./); + expect(prompt).toMatch(/sitzt neben dem Nutzer/); + expect(prompt).toMatch(/warm, neugierig\s+und zurückhaltend/); + expect(prompt).toMatch(/diagnostizierst nicht/); + }); + + it("carries the numbered GRUNDREGELN section", () => { + expect(prompt).toMatch(/GRUNDREGELN/); + expect(prompt).toMatch(/1\. Fließtext zuerst\./); + expect(prompt).toMatch(/2\. Werte gehören in den Evidenz-Block\./); + expect(prompt).toMatch(/3\. Fehlende Daten sind eine Einladung/); + expect(prompt).toMatch(/4\. Zurückhaltende Sprache\./); + expect(prompt).toMatch( + /5\. Mikro-Moves aus dem Motivational Interviewing\./, + ); + expect(prompt).toMatch(/6\. Off-topic-Eingaben elegant umlenken\./); + expect(prompt).toMatch(/7\. Verankere jede Zahl im SNAPSHOT\./); + }); + + it("preserves the TAGES-LEVEL-MESSWERTE section verbatim", () => { + expect(prompt).toMatch(/TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE/); + expect(prompt).toMatch(/timeline\.recent/); + expect(prompt).toMatch(/timeline\.weekly/); + expect(prompt).toMatch(/14 Tage/); + }); + + it("teaches the ---KEYVALUES--- / ---END--- evidence-block contract", () => { + expect(prompt).toMatch(/EVIDENZ-BLOCK/); + expect(prompt).toContain("---KEYVALUES---"); + expect(prompt).toContain("---END---"); + expect(prompt).toMatch(/Höchstgrenze 8 Zeilen/); + }); + + it("includes the tone-calibration few-shots", () => { + const exampleOpens = (prompt.match(//g) ?? []).length; + const exampleCloses = (prompt.match(/<\/example>/g) ?? []).length; + expect(exampleOpens).toBeGreaterThanOrEqual(3); + expect(exampleOpens).toBe(exampleCloses); + expect(prompt).toMatch(/Bewegung sehe ich in deinem Tracking gerade nicht/); + expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); + }); +}); diff --git a/src/lib/ai/coach/keyvalues.ts b/src/lib/ai/coach/keyvalues.ts new file mode 100644 index 000000000..99cdcf45f --- /dev/null +++ b/src/lib/ai/coach/keyvalues.ts @@ -0,0 +1,180 @@ +/** + * v1.4.22 — Coach evidence-block (`---KEYVALUES---` … `---END---`) + * parser. + * + * The Coach prompt instructs the model to append, after its prose + * reply, a sentinel block carrying the load-bearing numbers it cited: + * + * ---KEYVALUES--- + * avg30 systolic: 138 [mmHg] (last30days) + * Tue 6 May: 142/88 [mmHg] + * ---END--- + * + * This module strips that block out of the streamed prose and parses + * each line into a `CoachKeyValue`. The result feeds the + * `provenance.keyValues` field that the UI renders as a collapsible + * disclosure. + * + * Defence-in-depth: + * - Hard cap 1 KB on the sentinel block (prompt-injection guard). + * - Hard cap 8 lines kept (per the prompt contract). + * - Each line is validated against `coachKeyValueSchema`; malformed + * lines are dropped silently. + * - When the sentinel pair is malformed (e.g. no closing `---END---`, + * unbalanced sentinels), the parser returns the original prose + * untouched and emits no keyValues — the caller logs a wide-event + * `coach.keyvalues.parse_failed` for ops visibility. + */ +import { coachKeyValueSchema, type CoachKeyValue } from "./types"; + +/** Sentinel block payload hard cap — 1 KB after the opening marker. */ +const SENTINEL_BYTE_CAP = 1024; +/** Max key/value rows kept inside the block (prompt-contract value). */ +const SENTINEL_LINE_CAP = 8; + +const OPEN_SENTINEL = "---KEYVALUES---"; +const CLOSE_SENTINEL = "---END---"; + +/** + * Parse one body line of the form + * `