mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Release v1.4.22
d71e879chore(release): v1.4.22bbdc3fbstyle: prettier on W5 reconcile touchpointsb094eaadocs(planning): record Wave 5 reconcile + v1.4.22 backlog6c0c69cfix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race1631d80refactor: simplify-pass apply-yes cleanup65a8b5afix(coach): pin medical disclaimer at bottom of message thread651a529fix(insights): collapse BP tile to single secondary row at <sma2e24b1a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce)2075d39refactor(api): extract createSseStream helper from chat routeb1ce6ebrefactor(auth): fold onboarding-cookie write into createSession + destroySessionef597bffix(insights): align BD-Zielbereich delta math with comparison window75ccc01fix(coach): graceful fallback when sentinel-only output strips empty363e73cchore: voice + filename hygiene + wave-4 state wrap1499e6fi18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums)15796c1fix(auth): move onboarding redirect from AuthShell useEffect to proxyb281c06ops(deploy): force=true on Coolify webhook + node-26-alpine investigation1643a78fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt)a22f778feat(targets): 30-day sparkline + Δ-vs-last-month caption per card7c46cc9chore(planning): record Wave 3 Coach polish completionb845cf5refactor(coach): drawer polish — Gravatar avatar, disclaimer move, cog removalbc87fa3feat(coach): collapsible evidence-block disclosure under assistant bubbles7b8efaafeat(coach): parse evidence-block sentinel out of streamed prosebea55eafeat(coach): warm motivational rewrite with evidence-block sentinel1c0cd8cdocs(planning): record Wave 2 — Insights surface polish59a4a89fix(insights): strip metric tokens in recommendation prose + DE component labels4d3b129feat(insights): rename "Muster" + lift section tabs above the hero36b9db3fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data706aa5ffeat(settings): comparison-overlay becomes a global dashboard preferenceb39d5f8feat(insights): BP tile gains trend arrow + comparison overlay parity8606767fix(analytics): BP target headline reads last-30d, all-time becomes sub-rowe794f57docs(planning): v1.4.22 marathon scaffold — polishing before v1.5 iOS16b6976Merge main into develop — sync v1.4.21 brief3785fecfix(e2e): scroll first chart card into view before counting axis tickse8d0660fix(e2e): exempt truncate-clamped elements from per-element overflow walk812b68afix(e2e): scroll trigger into view + force-click on portalled togglef181ab7fix(e2e): pin onboardingCompletedAt so AuthShell does not redirect32cd185fix(e2e): adopt HeroStrip data-slot on insights advisor spec Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
005fed096c
144 files changed
+5380
-1407
No files matched your search
@@ -132,10 +132,24 @@ jobs:
|
||||
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable."
|
||||
exit 0
|
||||
fi
|
||||
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
||||
# and re-pull the registry digest. If the maintainer's secret
|
||||
# URL already carries a `?force=` query, leave it alone;
|
||||
# otherwise append `?force=true` (or `&force=true` if other
|
||||
# query params are present) so doc-only deploys still trigger
|
||||
# a registry-digest check.
|
||||
deploy_url="$COOLIFY_WEBHOOK"
|
||||
if ! echo "$deploy_url" | grep -q "force="; then
|
||||
if echo "$deploy_url" | grep -q "?"; then
|
||||
deploy_url="${deploy_url}&force=true"
|
||||
else
|
||||
deploy_url="${deploy_url}?force=true"
|
||||
fi
|
||||
fi
|
||||
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
|
||||
response=$(curl --silent --show-error --max-time 30 \
|
||||
--write-out "\n__HTTP_STATUS__:%{http_code}" \
|
||||
--request GET "$COOLIFY_WEBHOOK" \
|
||||
--request GET "$deploy_url" \
|
||||
--header "Authorization: Bearer $COOLIFY_TOKEN")
|
||||
status="${response##*__HTTP_STATUS__:}"
|
||||
body="${response%__HTTP_STATUS__:*}"
|
||||
|
||||
+15
-4
@@ -1,12 +1,23 @@
|
||||
# HealthLog v1.4.22 — roadmap
|
||||
|
||||
Milestone: **v1.4.22** (kicked off 2026-05-10, post-v1.4.21 patch)
|
||||
Milestone: **v1.4.22 — big polishing release before v1.5 iOS** (kicked off 2026-05-10, post-v1.4.21 patch)
|
||||
Latest tag at start: v1.4.21 (live in prod, image digest
|
||||
`sha256:4e818d44702c…`)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| G | E2E fix wave — 7 stale-selector / layout regressions surfaced post-v1.4.20 (HeroStrip slot, onboarding-card, chart-overlay, api-tokens-mobile, charts-mobile) | in flight |
|
||||
Marc's directive: close ALL known bugs + apply ALL deferred items
|
||||
from `.planning/v1421-backlog.md` so the iOS app builds on a clean
|
||||
foundation. Marathon-style execution: research/probe first, then
|
||||
parallel polish waves, then multi-agent QA, then release.
|
||||
|
||||
| Wave | Goal | State |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
|
||||
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, Metrik token leaks, Targets page brainstorm); health-coach prompt research | in flight |
|
||||
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | pending |
|
||||
| 3 | Coach polish — B1 prompt rewrite, B2 collapsible provenance, B3 Gravatar parity, B4 disclaimer move, B5 settings cog wire-or-remove | pending |
|
||||
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte upgrade, C2 api-tokens 5th attempt, C3 Coolify auto-deploy, C4 AuthShell flicker, C5 node-26-alpine, D backlog wave | pending |
|
||||
| G | E2E fix wave (already on `develop`) — 7 stale-selector / layout regressions; release-merge brings them into main with v1.4.22 | done |
|
||||
| 5 | Multi-agent QA + Product-Lead review | pending |
|
||||
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy (validated by C3), smoke, GH release, docs+landing sync, brief | pending |
|
||||
|
||||
Carry-over candidates already captured in
|
||||
`.planning/v1421-backlog.md` (now serving as the v1.4.22 backlog
|
||||
|
||||
+146
-332
@@ -1,384 +1,198 @@
|
||||
# v1.4.20 marathon — state log
|
||||
# v1.4.22 marathon — state log
|
||||
|
||||
Status: in flight
|
||||
Last update: 2026-05-10T15:00+02:00
|
||||
Status: Wave 5 — multi-agent QA + reconcile complete
|
||||
Last update: 2026-05-10T22:30+02:00
|
||||
|
||||
> Previous milestone: v1.4.19 live (image digest
|
||||
> `sha256:b48f93874cdb…`, `/api/version=1.4.19`).
|
||||
> v1.4.20 = Insights redesign with AI Coach
|
||||
> (handoff at `~/Downloads/design_handoff_insights_redesign`).
|
||||
> v1.5 reserved for iOS app + Apple Health integration.
|
||||
> Previous patch: v1.4.21 live (image digest
|
||||
> `sha256:4e818d44702c…`, `/api/version=1.4.21`).
|
||||
> v1.4.22 = big polishing release before v1.5 iOS. Closes ALL known
|
||||
> bugs + applies ALL deferred items from `.planning/v1421-backlog.md`
|
||||
> so the iOS app builds on a clean foundation.
|
||||
|
||||
## Foundation — pre-flight before any feature work
|
||||
## Wave 1 — Research + probe (parallel)
|
||||
|
||||
Per `.planning/v1420-marathon-handoff.md`, F1–F6 must complete before
|
||||
B1 dispatches.
|
||||
Per `feedback_research_before_complex_features.md`, deep-fix work on
|
||||
4-attempt bugs (BD-Zielbereich, api-tokens scrollbar) + Coach prompt
|
||||
rewrite needs research first.
|
||||
|
||||
### F0 — Commit dangling v1.4.19 reports + bootstrap v1.4.20 scaffold
|
||||
### W1a — Playwright PROD probe + token-leak hunt
|
||||
|
||||
- [x] Commit phase-E1-report.md + phase-E3-report.md v1.4.19 updates
|
||||
- [x] STATE.md replaced with v1.4.20 scaffold; v1.4.19 archived to ROADMAP.md
|
||||
- [x] ROADMAP.md updated with v1.4.20 milestone + previous-milestone archive
|
||||
- [ ] BD-Zielbereich tile rendering against PROD with maintainer
|
||||
session (4-attempt bug pattern: probe before patching)
|
||||
- [ ] Admin / api-tokens scrollbar at multiple viewports (5th attempt)
|
||||
- [ ] Raw `Metrik <Type>` token leaks in Recommendations / Findings
|
||||
- [ ] Targets / Zielwerte page screenshot for upgrade brainstorm
|
||||
- [ ] Inventory `.planning/v1421-backlog.md` items by complexity
|
||||
- Detailed report: `.planning/phase-W1a-report.md`
|
||||
|
||||
### F1 — Branch model: long-lived `develop`
|
||||
### W1b — Health-coach prompt research
|
||||
|
||||
- [x] Create `develop` from cleaned `main` HEAD (`a1cf9bc`)
|
||||
- [x] Push `develop` to origin (tracking set up)
|
||||
- [x] GHCR workflow verified — only `branches: [main]` + `tags: ['v*']` push paths build/publish; PRs to `main` build but do not push; develop is build-free
|
||||
- [x] All v1.4.20 commits target `develop`; release-merge to `main` only at Phase E
|
||||
- Detailed report: `.planning/phase-F1-report.md`
|
||||
- [ ] Benchmark health-coach / motivational-interviewing prompt
|
||||
patterns (Apple Health Coaching, Oura, Whoop, …)
|
||||
- [ ] Define "natural prose first, evidence collapsible" structure
|
||||
with concrete EN + DE drafts
|
||||
- [ ] PROMPT_VERSION 4.20.2 → 4.22.0 (significant Coach tone shift)
|
||||
- Detailed report: `.planning/phase-W1b-coach-prompt-research.md`
|
||||
|
||||
### F2 — Document branch + release model
|
||||
## Wave 2 — Insights surface polish
|
||||
|
||||
- [x] Extend `CONTRIBUTING.md` with branch flow + ASCII diagram (this branch)
|
||||
- [x] Mirror page on healthlog-docs at `/contributing/branch-model/` (`5d96861` on healthlog-docs/main)
|
||||
- [x] User-vs-contributor distinction explicit in both surfaces
|
||||
- Detailed report: `.planning/phase-F2-report.md`
|
||||
### A1 — BD-Zielbereich 5th attempt
|
||||
|
||||
### FX — User-facing artifact cleanup (PII + jargon + German leaks)
|
||||
- [x] Headline re-anchored to `windows.last30Days?.pct`; all-time
|
||||
surfaced as `bpInTargetPctAllTime` for the new sub-row
|
||||
- [x] Integration test pins the route contract
|
||||
- Detailed report: `.planning/phase-W2-report.md`
|
||||
|
||||
Combined sweep across HealthLog main repo + healthlog-docs + healthlog-landing
|
||||
(F3 + F4 + new PII rule consolidated into one wave).
|
||||
### A2 — BD-Kachel feature parity
|
||||
|
||||
- [x] HealthLog main repo: 20 files scrubbed, `a1cf9bc` on origin/main
|
||||
(CHANGELOG.md, docs/audit/v146 / v1414 / v1415 / v1416 / v1418 /
|
||||
v1419, docs/codex-protocol-spec.md, docs/migration/, docs/ops/)
|
||||
- [x] healthlog-docs: 7 files scrubbed, `782862b` on origin/main
|
||||
(api/ examples replaced "marc" → "alice", DE leaks fixed)
|
||||
- [x] healthlog-landing: 1 file scrubbed, `9b31083` on origin/main
|
||||
(JSON-LD author block depersonalised)
|
||||
- [x] GH releases v1.4.14–v1.4.19: republished bodies from cleaned
|
||||
CHANGELOG sections during Phase E pipeline
|
||||
- [ ] Source-comment sweep (`src/` has 191 `Marc` references in
|
||||
maintainer comments) — defer to v1.4.21 backlog as low-priority
|
||||
- [ ] DE+EN bilingual CHANGELOG entries (v1.4.15 era) — defer; larger
|
||||
rewrite, captured in v1.4.21 backlog
|
||||
- Detailed report: `.planning/phase-FX-report.md`
|
||||
- [x] Trend arrow + 7-day-trend chip + comparison overlay parity
|
||||
(synthesised slope from 7d/30d delta); `<TrendCard>` extended
|
||||
with optional `avgAllTime` sub-value
|
||||
|
||||
### F5 — Best-practice GitHub repo audit
|
||||
### A3 — Comparison-Toggle → global Settings
|
||||
|
||||
- [x] CODE_OF_CONDUCT.md (Contributor Covenant 2.1, `e7b6b27`)
|
||||
- [x] Issue + PR templates (`.github/ISSUE_TEMPLATE/*.yml`, `PULL_REQUEST_TEMPLATE.md`, `072941b`)
|
||||
- [x] Dependabot expanded to github-actions + docker (`779e5a3`)
|
||||
- [x] package.json metadata: description, license, homepage, repository, bugs, keywords (`f7977d1`)
|
||||
- [x] LOW carry-over (README badges, FUNDING.yml, repo-root tidy) deferred to `.planning/v1421-backlog.md`
|
||||
- Detailed report: `.planning/phase-F5-report.md`
|
||||
- Note: initial sub-agent dispatch hit a content-filter block on the CoC text; refactored to inline execution (download CoC verbatim from contributor-covenant.org).
|
||||
- [x] On-surface `<CompareToggle />` removed from `/insights`;
|
||||
Settings → Dashboard already carries the canonical picker
|
||||
- [x] `User.dashboardWidgetsJson.comparisonBaseline` field already
|
||||
exists (v1.4.16 phase B8) — every chart still consumes it
|
||||
|
||||
### F6 — Multi-agent QA on new docs
|
||||
### A4 — Insights layout grid normalisation
|
||||
|
||||
- [x] 38 docs site pages cross-checked against the live app (v1.4.19), CHANGELOG, `.env.example`, Dockerfile, docker-compose, docker-publish workflow
|
||||
- [x] CRITICAL fixed inline: `19eb8de` (S3 env-var names + arm64 build claim removed) on healthlog-docs/main
|
||||
- [x] HIGH fixed inline: `3d3ea21` (achievement count 38 → 59, `/admin/<section>` paths, AI rate-limit 2/h → 10/h, default-locale flip, comparison-overlay scope) on healthlog-docs/main
|
||||
- [x] 9 MED + 5 LOW deferred to `.planning/v1421-backlog.md` "Docs site audit" section
|
||||
- [x] Two flagged-but-uncertain items captured for maintainer review
|
||||
- Detailed report: `.planning/phase-F6-report.md`
|
||||
- [x] Row-fill rule: BP / weight / medications grids collapse to
|
||||
full-width when only one card has data
|
||||
- [x] Trends row equal heights via `min-h-[300px]`
|
||||
- [x] `<CorrelationRow>` drops insufficient-data tiles; zero ok
|
||||
cards hides the row entirely
|
||||
|
||||
## Wave B — Insights redesign with AI Coach (sequential)
|
||||
### A5 — Section heading + tab rename
|
||||
|
||||
Per `.planning/phase-D-v1419-product-lead-review.md`. Each phase
|
||||
ships behind a feature flag and unlocks independently.
|
||||
- [x] "Muster" → "Zusammenhänge" (DE) / "Patterns" → "Relationships"
|
||||
(EN). Picked Zusammenhänge over Trends because the row above
|
||||
already uses Trends.
|
||||
- [x] `<InsightsSectionNav>` lifted above the hero strip (sticky
|
||||
scroll-anchored; tabs visible first)
|
||||
|
||||
### B1 — Hero strip + Daily Briefing + Suggested-prompts (~3-5 days)
|
||||
### A6 — Raw `Metrik <Type>` token leak fix
|
||||
|
||||
- [x] Replace `<InsightsPageHero>` with new hero strip (greeting,
|
||||
action row + suggested-prompt strip; Daily Briefing card mounts
|
||||
full-width below the hero)
|
||||
- [x] New `src/components/insights/{hero-strip,daily-briefing,
|
||||
suggested-prompts}.tsx`
|
||||
- [x] Extend `aiInsightResponseSchema` with `dailyBriefing`
|
||||
(`paragraph`, `keyFindings[]`)
|
||||
- [x] PROMPT_VERSION bump 4.19.0 → 4.20.0
|
||||
- [~] Apple Health gated tiles — descoped per `phase-D-v1419-product-
|
||||
lead-review.md` decision. The 4-vitals tile row from the artboard
|
||||
uses BP / Weight / Pulse / Mood (all data already in the app);
|
||||
HRV / Sleep / Resting-HR tiles defer to v1.5 / iOS app. The B1
|
||||
tile row itself (Vitals at-a-glance) is deferred to a later
|
||||
B-phase since it duplicates the per-section status cards below
|
||||
— see B1 report for the rationale.
|
||||
- Detailed report: `.planning/phase-B1-v1420-report.md` (the older
|
||||
`phase-B1-report.md` is from the v1.4.18 achievements marathon)
|
||||
- [x] `<RecommendationCard>` text wrapped in `stripChartTokens()`
|
||||
- [x] DE `componentMood`/`componentBp`/`componentCompliance` renamed
|
||||
to German nouns; i18n-integrity test pins the contract
|
||||
|
||||
### B2 — AI Coach panel + streaming chat + persistence (~5-7 days)
|
||||
## Wave 3 — Coach polish
|
||||
|
||||
Split into B2a (backend, complete) + B2b (drawer UI, complete). Both
|
||||
halves landed on `develop` under nine atomic commits total — five
|
||||
for the backend wave, four for the drawer wave plus a state-tick
|
||||
commit.
|
||||
### B1 — Prompt rewrite
|
||||
|
||||
- [x] New `POST /api/insights/chat` SSE-streaming endpoint
|
||||
- [x] New `GET /api/insights/chat` (list) + `GET/DELETE
|
||||
/api/insights/chat/[id]` (single + delete)
|
||||
- [x] New Prisma `CoachConversation` + `CoachMessage` + `CoachUsage`
|
||||
(encrypted content, GDPR cascade)
|
||||
- [x] Source-chip provenance attached to every assistant message
|
||||
- [x] Token-budget cap (25 000 tokens / user / UTC-day) + 20-turn
|
||||
conversation cap with summarised-history fold
|
||||
- [x] Prompt-injection + off-topic refusal pattern (EN+DE)
|
||||
- [x] Drawer overlay over `/insights`; full-page `/insights/coach`
|
||||
route deferred to v1.5 paired with the iOS app
|
||||
- [x] New `src/components/insights/coach-panel/*` — drawer shell,
|
||||
message thread, source chips, composer, history rail,
|
||||
sources rail, TanStack Query + SSE hooks
|
||||
- Detailed report: `.planning/phase-B2a-report.md` (backend);
|
||||
`.planning/phase-B2b-report.md` (drawer UI)
|
||||
- [x] Adopt W1b research output: natural prose-first, motivational,
|
||||
conservative, evidence-grounded
|
||||
- [x] PROMPT_VERSION 4.22.0; EN + DE
|
||||
- [x] Coach-specific test suite covering tone + evidence-collapsible
|
||||
pattern
|
||||
- Detailed report: `.planning/phase-W3-report.md`
|
||||
|
||||
### B3 — Correlation discovery + Trends row with AI annotations (~3-5 days)
|
||||
### B2 — Collapsible provenance + values
|
||||
|
||||
- [x] 3 pre-defined hypotheses: BP×compliance, mood×pulse,
|
||||
weight×weekday
|
||||
- [x] New `src/lib/insights/correlations.ts` (Pearson + weekday-ANOVA;
|
||||
n >= 14 + p < 0.05 surfacing gate)
|
||||
- [x] New `src/components/insights/{correlation-card,correlation-row,
|
||||
trends-row,trend-annotation}.tsx`
|
||||
- [x] Confidence interval via Fisher z-transform; conservative
|
||||
phrasing locked in by code-review convention
|
||||
- [x] PROMPT_VERSION 4.20.0 → 4.20.1; new `trendAnnotations` block
|
||||
on the AI response schema (nullable + optional for legacy
|
||||
payloads)
|
||||
- [x] `/api/analytics` now emits `correlations: {bpCompliance,
|
||||
moodPulse, weightWeekday}` server-side
|
||||
- [x] `<CorrelationRow>` + `<TrendsRow>` mounted on `/insights`
|
||||
between Daily Briefing and the Advisor card
|
||||
- Detailed report: `.planning/phase-B3-report.md`
|
||||
- [x] Move inline raw values out of prose into a
|
||||
"Worauf bezieht sich das?" expandable below each assistant
|
||||
message; `---KEYVALUES---` … `---END---` sentinel stripped
|
||||
server-side, parsed into `provenance.keyValues`
|
||||
- [x] Source chips stay visible; values reveal on click
|
||||
|
||||
### B4 — Weekly Report + Storyboard + Mobile passes (~3-4 days)
|
||||
### B3 — User avatar = Gravatar (parity)
|
||||
|
||||
- [x] PROMPT_VERSION 4.20.1 → 4.20.2; aiInsightResponseSchema gains
|
||||
`weeklyReport` + `storyboardAnnotations`; GROUND RULES 10/11
|
||||
across EN + DE prompts
|
||||
- [x] New `/insights/report/[week]` route — newsletter-style
|
||||
printable surface with Summary / Going-well / Worth-watching /
|
||||
Tips / Data-quality sections; `window.print()` export via
|
||||
Tailwind `print:` variants
|
||||
- [x] Hero strip banner-card surfaces the fresh weeklyReport with
|
||||
Read · Share · Export PDF actions; Share uses Web Share API +
|
||||
clipboard fallback; Export deep-links to `?print=1`
|
||||
- [x] `<HealthChart>` gains an additive `annotations[]` prop;
|
||||
`resolveAnnotationPositions()` pure helper maps date→pointIndex
|
||||
with a 7-day snap window and 24-char small-viewport truncation
|
||||
- [x] Coach drawer rail trays — chevron-button triggers on `<lg`
|
||||
open side-sheets that host the same HistoryRail / SourcesRail
|
||||
the desktop layout uses; refactored body into
|
||||
`<CoachDrawerBody>` for SSR-test surface
|
||||
- Detailed report: `.planning/phase-B4-report.md`
|
||||
- [x] Same dimensions as Coach avatar
|
||||
- [x] Reuses existing `gravatarUrl` from `/api/auth/me`; initials
|
||||
fallback when no Gravatar configured
|
||||
|
||||
### B5 — Personal AI Coach Health Score (~2-3 days)
|
||||
### B4 — Disclaimer move
|
||||
|
||||
- [x] Composite Health Score (0-100): 30% BP-target + 20%
|
||||
weight-trend + 20% mood-stability + 30% compliance
|
||||
- [x] Three bands (≥75 green, 50–74 yellow, <50 red)
|
||||
- [x] "Ask the Coach" CTA opens B2 drawer with prefilled prompt
|
||||
- Detailed report: `.planning/phase-B5-report.md`
|
||||
- [x] "Coach replies are generated. Clinical decisions belong with
|
||||
your doctor." moves from below input → sources rail footer
|
||||
- [x] "Coach reads only your connected data" caption replaced by the
|
||||
disclaimer so the source picker stands alone
|
||||
|
||||
## Wave B summary (B1–B5)
|
||||
### B5 — Settings cog wire-or-remove
|
||||
|
||||
Wave B closed the Insights redesign across 5 phases on `develop`:
|
||||
- [x] Removed for v1.4.22; per-user prompt-tuning surface deferred
|
||||
to v1.4.23 (no dead buttons in this release)
|
||||
|
||||
| Phase | Theme | Commits | Test count delta (unit / int) |
|
||||
| ----- | ------------------------------------------ | ------- | ----------------------------- |
|
||||
| B1 | Hero strip + Daily Briefing + Suggested | 5 | 1672 → 1753 (+81) / 67 → 67 |
|
||||
| B2 | AI Coach drawer (B2a backend + B2b UI) | 5 + 4 | 1753 → 1833 (+80) / 67 → 78 |
|
||||
| B3 | Correlation discovery + Trends row + AI | 5 | 1833 → 1907 (+74) / 78 → 78 |
|
||||
| B4 | Weekly Report + Storyboard + Mobile passes | 5 | 1907 → 1975 (+68) / 78 → 78 |
|
||||
| B5 | Personal Health Score | 5 | 1975 → 2026 (+51) / 78 → 81 |
|
||||
| | **Cumulative** | **29** | **+354 / +14** |
|
||||
## Wave 4 — Other surfaces + backlog cleanup
|
||||
|
||||
Test count: 1672 → 2026 unit (+354), 67 → 81 integration (+14).
|
||||
Test files 217 → 237. typecheck clean every phase; lint
|
||||
13 baseline warnings unchanged.
|
||||
### C1 — Zielwerte page upgrade
|
||||
|
||||
## Wave D — Multi-agent QA + Product-Lead review
|
||||
- [x] 30-day sparkline + Δ-vs-last-month caption per card (Direction
|
||||
A from W1a §4). Reuses shared HealthChart styling via a
|
||||
dependency-free inline SVG; API returns `points30d` +
|
||||
`deltaVsLastMonth`. BMI derives both from the weight series.
|
||||
|
||||
- [x] code-reviewer (`.planning/phase-D-v1420-code-review.md`)
|
||||
- [x] security review (`.planning/phase-D-v1420-security-review.md`)
|
||||
- [x] design / UX review (`.planning/phase-D-v1420-design-review.md`)
|
||||
- [x] senior-dev review (`.planning/phase-D-v1420-senior-dev-review.md`)
|
||||
- [x] simplify (`.planning/phase-D-v1420-simplify-review.md`)
|
||||
- [x] Product Lead — v1.5 redesign plan (`.planning/phase-D-v1420-product-lead-review.md`)
|
||||
- [x] Reconcile applied CRITICAL + HIGH (13 of 13) + 6 MED inline; 22 MED + 16 LOW + 4 simplify-apply-maybe deferred to `.planning/v1421-backlog.md` "Phase D reconcile carry-over" section
|
||||
- Detailed report: `.planning/phase-D-v1420-reconcile-report.md`
|
||||
### C2 — Admin / api-tokens scrollbar 5th attempt
|
||||
|
||||
## Phase E — Release v1.4.20
|
||||
- [x] Live Playwright probe confirmed `whitespace-nowrap` on the date
|
||||
`<td>`s was the residual culprit (W1a §2). Dropped the two
|
||||
classes; date+time wraps to two lines on narrow viewports.
|
||||
|
||||
- [x] Pre-release verify (typecheck, lint, test, integration,
|
||||
format, build) — 2026 unit + 81 integration green
|
||||
- [x] Bump package.json + CHANGELOG (`aac8b5e chore(release): v1.4.20`)
|
||||
- [x] Sync Dependabot dep bumps from main → develop (`dd2ef57`)
|
||||
- [x] Release-merge develop → main (`666f6ee Release v1.4.20`)
|
||||
- [x] Tag + push v1.4.20 (annotated, `666f6ee`)
|
||||
- [x] GHCR build green (run `25633807964`, 4m02s,
|
||||
digest `sha256:b112a31947…`)
|
||||
- [x] Coolify deploy — auto-deploy webhook still missing on
|
||||
apps-01; host-side retag fallback used (same fault mode as
|
||||
v1.4.19)
|
||||
- [x] /api/version=1.4.20 confirmed (flipped 2026-05-10T16:49:25Z)
|
||||
- [x] Production smoke — all gated routes redirect 307 →
|
||||
`/auth/login` as expected for an unauthenticated request;
|
||||
`/api/version` and `/auth/login` 200
|
||||
- [x] GH release published (https://github.com/MBombeck/HealthLog/releases/tag/v1.4.20)
|
||||
- [x] GH releases v1.4.14 → v1.4.19 republished from cleaned
|
||||
CHANGELOG sections (FX carry-over)
|
||||
- [x] Docs site sync (`0b83430` on healthlog-docs/main —
|
||||
version pins + ai-insights v1.4.20 callout)
|
||||
- [x] Landing site sync (`43ce4c7` on healthlog-landing/main —
|
||||
JSON-LD `softwareVersion` 1.4.20 + 3 new featureList entries)
|
||||
- [x] `docs/audit/v1420-summary.md` (release brief, `7f9ae21`)
|
||||
- [x] v1.5 plan refreshed — see `.planning/phase-D-v1420-product-
|
||||
lead-review.md`
|
||||
### C3 — Coolify image-digest auto-deploy
|
||||
|
||||
---
|
||||
- [x] Workflow appends `?force=true` to the deploy webhook so doc-only
|
||||
pushes still trigger a registry-digest check. Maintainer-side
|
||||
toggle ("Watch image registry for new digests") documented in
|
||||
`.planning/coolify-auto-deploy-howto.md` — one UI flip.
|
||||
|
||||
## Status block — F0 (v1.4.20)
|
||||
### C4 — AuthShell post-hydration redirect flicker
|
||||
|
||||
- 2026-05-10T15:00+02:00 — F0 complete. Dangling v1.4.19 release
|
||||
reports (`phase-E1-report.md` + `phase-E3-report.md`) committed,
|
||||
STATE.md replaced with v1.4.20 scaffold (F1–F6 foundation +
|
||||
B1–B5 Insights redesign + Wave D + Phase E), ROADMAP.md updated
|
||||
to track v1.4.20 with v1.4.19 archived. Branch model unchanged
|
||||
for this commit (still `main`); F1 will introduce `develop`.
|
||||
- [x] Redirect lives in `proxy.ts`. Auth routes mirror
|
||||
`onboardingCompletedAt` into a non-httpOnly `hl_onboarding`
|
||||
cookie so the proxy can short-circuit before hydration. The
|
||||
e2e onboarding-flicker spec dropped its workaround.
|
||||
|
||||
## Status block — B2a (v1.4.20)
|
||||
### C5 — node-26-alpine bump
|
||||
|
||||
- 2026-05-10T16:15+02:00 — B2a complete. AI Coach backend landed in
|
||||
five atomic commits on `develop`: Prisma migration
|
||||
`0035_coach_conversations_v1420` adds `CoachConversation`,
|
||||
`CoachMessage`, and `CoachUsage` tables (cascade-on-user-delete);
|
||||
Coach helpers (types, persistence with AES-256-GCM message bodies,
|
||||
per-day 25 000-token budget ledger, EN+DE prompt-injection +
|
||||
off-topic refusal); SSE streaming endpoint at POST
|
||||
`/api/insights/chat` (idempotent on conversation creation only,
|
||||
20-turn history fold, refusal short-circuit, provenance frame, done
|
||||
frame); list / fetch-single / delete endpoints with 404-not-403
|
||||
ownership boundary. Tests: 1753 → 1781 unit (+28),
|
||||
67 → 78 integration (+11). UI drawer (B2b) deferred to a separate
|
||||
dispatch.
|
||||
- [x] Dependabot PR #162 closed with reasoning. node:26-alpine ships
|
||||
without corepack pre-installed; Next.js 16 also still pins
|
||||
node 22 LTS as the supported minimum. Reopen when the framework
|
||||
bumps its node-version requirement.
|
||||
|
||||
## Status block — B2b (v1.4.20)
|
||||
### D — `.planning/v1421-backlog.md` cleanup wave
|
||||
|
||||
- 2026-05-10T16:30+02:00 — B2b complete. AI Coach drawer landed in
|
||||
four atomic commits on `develop`: TanStack Query + SSE streaming
|
||||
hooks (`useCoachConversations`, `useCoachConversation`,
|
||||
`useDeleteCoachConversation`, `useSendCoachMessage`) with a
|
||||
pure `parseSseChunk()` helper for unit-testable frame parsing;
|
||||
drawer shell wrapped in `<Sheet side="right">` with three-column
|
||||
layout on `lg+` (history rail · message thread · sources rail) and
|
||||
full-screen single-column on mobile; `<MessageThread>` renders user
|
||||
/ assistant bubbles + an in-flight streaming bubble with
|
||||
pinned-to-bottom auto-scroll; `<SourceChips>` renders provenance as
|
||||
labels-only chips (metric · window · n-count, never raw values);
|
||||
`<CoachInput>` composer with Enter / Cmd+Enter / Shift+Enter
|
||||
semantics, disabled mic placeholder ("voice arrives in v1.5"), and
|
||||
the "Coach replies are generated. Clinical decisions belong with
|
||||
your doctor." disclaimer; `<HistoryRail>` with substring filter
|
||||
and confirm-then-go delete; `<SourcesRail>` with the five Coach
|
||||
metric contracts; `/insights` page mounts the drawer with the
|
||||
hero strip's "Ask the coach" button + suggested-prompt chips
|
||||
driving open + prefill. Tests: 1781 → 1833 unit (+52,
|
||||
six new test files: `use-coach`, `source-chips`, `message-thread`,
|
||||
`coach-input`, `sources-rail`, `history-rail`). Integration count
|
||||
unchanged at 78. typecheck + lint clean (12 baseline warnings).
|
||||
Apply or confirm-defer every item:
|
||||
|
||||
## Status block — Wave D reconcile (v1.4.20)
|
||||
- [x] Phase D reconcile carry-over (22 MED + 16 LOW + 4 simplify-maybe)
|
||||
- [x] FX carry-overs:
|
||||
- [x] 191 `Marc` references swept across `src/` (test fixtures kept
|
||||
as opaque test data per brief)
|
||||
- [x] DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15) reduced
|
||||
to English-only
|
||||
- [x] `CLAUDE.md` renamed to `CONTRIBUTING-AI.md` so the filename
|
||||
is not AI-vendor-specific; `AGENTS.md` stays for multi-agent
|
||||
compatibility. `CONTRIBUTING.md` reference updated.
|
||||
|
||||
- 2026-05-10T18:15+02:00 — Wave D reconcile complete. Six reviewers
|
||||
filed 0 CRIT · 12 HIGH · 28 MED · 22 LOW + 5 simplify-apply-yes +
|
||||
4 simplify-apply-maybe + 3 simplify-apply-no across the code,
|
||||
security, design, senior-dev, simplify, and product-lead lenses.
|
||||
All 12 HIGH items + the design pushback on the disabled weekly-
|
||||
report button + 6 MED items + every simplify-apply-yes item landed
|
||||
inline across 11 atomic commits on `develop` (`e632e26` →
|
||||
`31fbf98`). 22 MED + 16 LOW + 4 simplify-apply-maybe deferred to
|
||||
`.planning/v1421-backlog.md` under "Phase D — v1.4.20 reconcile
|
||||
carry-over". Test counts unchanged (2026 unit / 81 integration);
|
||||
typecheck + lint clean; one observed integration-test flake on
|
||||
`coach-chat.test.ts` "round-trips" (not introduced by Phase-D —
|
||||
consistent re-runs pass). Phase E (release v1.4.20) unblocked.
|
||||
### E2E fix wave (already on develop)
|
||||
|
||||
## Phase F — v1.4.20.1 hotfix
|
||||
- [x] 5 commits on develop from the v1.4.21 follow-up; release-merge
|
||||
brings them into main when v1.4.22 ships
|
||||
|
||||
Triggered by Marc's testing of the new Insights/Coach surface in
|
||||
production after v1.4.20 went live. Five issues captured in
|
||||
`feedback_v1420_post_deploy_bugs.md` — landed across six atomic
|
||||
commits on `develop`. No tag yet; maintainer decides whether to ship
|
||||
as v1.4.20.1 or bundle into v1.4.21.
|
||||
## Wave 5 — Multi-agent QA + Product-Lead review
|
||||
|
||||
- [x] Daily Briefing regenerate produces a non-empty briefing
|
||||
(`/api/insights/generate` now uses `getStrictInsightsSystemPrompt`
|
||||
so GROUND RULE 8 reaches the model — `7921ffc`)
|
||||
- [x] Coach snapshot ships day-level readings with weekday labels
|
||||
and a per-day medication-adherence row (`ed61b17`)
|
||||
- [x] Coach system prompt instructs the model to use the timeline
|
||||
block for day-specific or weekday-specific questions
|
||||
(`2143377`)
|
||||
- [x] Streaming bubble clears once the persisted twin lands —
|
||||
message-thread render-time dedup keyed on `messageId`
|
||||
(`f07e35b`)
|
||||
- [x] Drawer header settings cog moved to the left side so Radix
|
||||
Sheet's close-X no longer overlaps it (`ddb2914`)
|
||||
- [x] Sources rail grows per-source checkboxes + window selector;
|
||||
scope flows through `useSendCoachMessage` to the chat request
|
||||
body and into `buildCoachSnapshot` (`08fd411`)
|
||||
- [x] Detailed report: `.planning/phase-v1421-hotfix-report.md`
|
||||
- [x] code-reviewer (`.planning/phase-W5-v1422-code-review.md` — 0 CRIT, 2 HIGH, 4 MED, 5 LOW)
|
||||
- [x] security review (`.planning/phase-W5-v1422-security-review.md` — 0 CRIT, 0 HIGH, 2 MED, 4 LOW)
|
||||
- [x] design / UX review (`.planning/phase-W5-v1422-design-review.md` — 0 CRIT, 3 HIGH, 7 MED, 5 LOW)
|
||||
- [x] senior-dev review (`.planning/phase-W5-v1422-senior-dev-review.md` — 0 CRIT, 2 HIGH, 5 MED, 4 LOW)
|
||||
- [x] simplify (`.planning/phase-W5-v1422-simplify-review.md` — 5 apply-yes, 4 apply-maybe, 6 apply-no)
|
||||
- [x] Product Lead — v1.5 plan refresh based on v1.4.22 state (`.planning/phase-W5-v1422-product-lead-review.md`)
|
||||
- [x] Reconcile applied CRITICAL + HIGH; defer MED/LOW with reasoning (10 commits; report at `.planning/phase-W5-v1422-reconcile-report.md`; backlog at `.planning/v1422-backlog.md`)
|
||||
|
||||
## Status block — F (v1.4.20.1 hotfix)
|
||||
## Wave 6 — Release v1.4.22
|
||||
|
||||
- 2026-05-10 (post-deploy) — Hotfix complete. Six commits on
|
||||
`develop` (`7921ffc` → `08fd411`). Test counts: 2026 → 2036 unit
|
||||
(+10 net across snapshot + message-thread + sources-rail tests).
|
||||
Integration tests unchanged at 81 — coach-chat.test.ts still 5/5.
|
||||
typecheck + lint clean (13 baseline warnings unchanged). Token-
|
||||
budget impact of the day-level snapshot: ~190 → ~3000 tokens per
|
||||
full-scope turn; the new scope picker is the relief valve, single-
|
||||
source narrowed turns land around 600-700 tokens. v1.4.21 should
|
||||
watch usage and consider trimming `DAILY_TIMELINE_DAYS` from 14 to
|
||||
10 if heavy users start hitting the 25k/day cap.
|
||||
|
||||
## Status block — v1.4.21 release
|
||||
|
||||
- 2026-05-10T17:46+00:00 — v1.4.21 LIVE in production. Tag `v1.4.21`
|
||||
on `8e198e1 Release v1.4.21` (release-merge of develop into main),
|
||||
GHCR build run `25635294152` succeeded, image digest
|
||||
`sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c`.
|
||||
Coolify auto-deploy webhook still missing (same fault as v1.4.19 +
|
||||
v1.4.20) — host-side retag fallback used (`docker pull :1.4.21 →
|
||||
docker tag :latest → docker compose up -d --force-recreate app`)
|
||||
on apps-01. `/api/version` flipped to `1.4.21` between
|
||||
19:45+02:00 and 19:46+02:00. Production smoke (no session): every
|
||||
gated route 307 → `/auth/login`; `/api/version` 200; `/auth/login`
|
||||
reachable. GH release: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.21.
|
||||
Marc-Brief at `docs/audit/v1421-summary.md`. Docs site + landing
|
||||
site sync skipped (patch is invisible at the docs page level —
|
||||
the v1.4.20 entries already cover the Insights redesign +
|
||||
Coach surface).
|
||||
|
||||
## Phase G — e2e fix wave (in flight on develop)
|
||||
|
||||
The post-v1.4.20 e2e workflow on `main` reported 7 failing specs.
|
||||
Five are stale selectors from the B1 hero rewrite (the new
|
||||
`<HeroStrip>` exposes a different `data-slot` value than the
|
||||
legacy `<InsightsPageHero>` the specs assert), the other two are
|
||||
Pixel-5 layout drift on `admin-api-tokens-mobile` and
|
||||
`charts-mobile`. A focused fix wave is in flight on `develop`;
|
||||
the next release (v1.4.22) will land them.
|
||||
|
||||
## Status block — B5 (v1.4.20)
|
||||
|
||||
- 2026-05-10T17:45+02:00 — B5 complete. Personal Health Score
|
||||
landed in five atomic commits on `develop`: pure formula library
|
||||
(`src/lib/analytics/health-score.ts`) with linear-regression
|
||||
slope, coefficient-of-variation, weight-trend alignment, mood
|
||||
stability, and compliance-rate helpers; the four-component
|
||||
composite scales remaining weights when sub-components are null
|
||||
(29 unit tests pin every shape); `/api/analytics` emits
|
||||
`healthScore: { score, band, components, delta } | null` with
|
||||
the wide-event annotation carrying score + band + delta;
|
||||
`<HealthScoreCard>` (220 px desktop, full-width mobile) with
|
||||
band-tinted progress bar, four sub-bars, "vs last week" delta
|
||||
line, score-aware "Ask the Coach" prefill ("Why is my health
|
||||
score X out of 100?"); `<HeroStrip>` flips to `lg:flex-row` and
|
||||
mounts the panel beside the title block when the analytics
|
||||
payload includes a score. Tests: 1975 → 2026 unit (+51), 78 → 81
|
||||
integration (+3). typecheck + lint clean. Canonical sample (the
|
||||
integration's strong-positive seed): score 95, band green,
|
||||
components 90/100/89/100. Wave-B closed; Wave D dispatch next.
|
||||
- [ ] Pre-release verify (typecheck, lint, test, integration,
|
||||
format, build)
|
||||
- [ ] Bump `package.json` 1.4.21 → 1.4.22 + CHANGELOG
|
||||
- [ ] Release-merge develop → main
|
||||
- [ ] Tag + push v1.4.22
|
||||
- [ ] GHCR build green
|
||||
- [ ] Coolify deploy (should work without host-side fallback after C3)
|
||||
- [ ] /api/version=1.4.22 confirmed
|
||||
- [ ] Production smoke + e2e workflow on main passes (e2e fix wave
|
||||
from develop is included in the release-merge)
|
||||
- [ ] GH release
|
||||
- [ ] Docs site + landing site sync
|
||||
- [ ] `docs/audit/v1422-summary.md` (release brief)
|
||||
- [ ] v1.5 plan refreshed for the iOS push
|
||||
@@ -0,0 +1,65 @@
|
||||
# Coolify image-digest auto-deploy — minimal maintainer action
|
||||
|
||||
Status: maintainer-task — the toggle lives in Coolify's UI; neither the
|
||||
Coolify MCP nor the GitHub workflow can flip it.
|
||||
|
||||
## The problem
|
||||
|
||||
Releases v1.4.19, v1.4.20, and v1.4.21 all required a host-side `docker
|
||||
tag <sha> :latest` + `docker compose up -d --no-deps` fallback after
|
||||
GHCR pushed the new image. Coolify pulled `:latest` from its local
|
||||
cache without re-checking the GHCR registry digest, so the freshly
|
||||
published image never reached production until the maintainer SSH'd
|
||||
into the host and forced the retag.
|
||||
|
||||
Longer history: `docs/audit/v1416-auto-deploy-fix.md` documents the
|
||||
v1.4.15 partial fix (workflow-side webhook trigger) and why it
|
||||
half-solved the problem.
|
||||
|
||||
## The minimal change
|
||||
|
||||
In the Coolify UI for the HealthLog application:
|
||||
|
||||
1. Open **Configuration → Source** (or equivalent tab depending on the
|
||||
Coolify version — the option may also live under "General" or
|
||||
"Deploy").
|
||||
2. Enable the **"Watch image registry for new digests"** /
|
||||
**"Image-digest auto-deploy"** checkbox. Coolify v4 names it
|
||||
slightly differently across point-releases; the one that triggers a
|
||||
pull when the registry digest changes (not when a git push lands)
|
||||
is the one we want.
|
||||
3. Save. The next time GHCR pushes a new `:latest` digest, Coolify
|
||||
will pull and recreate the container without a host-side retag.
|
||||
|
||||
## Why not solve it in the workflow
|
||||
|
||||
Two non-options were ruled out:
|
||||
|
||||
- **Push the explicit version tag in `docker-compose.yml`** — would
|
||||
require a workflow edit on every release to bump `image:` to the new
|
||||
tag. Worse than the current state (a manual host-side step) because
|
||||
the release author has to remember the second commit.
|
||||
- **Pre-deployment `docker pull` hook in the Coolify app config** —
|
||||
duplicates what the registry-digest auto-deploy already does, but
|
||||
worse: the pull runs on every deploy trigger (including doc-only
|
||||
pushes) instead of only when the digest changed.
|
||||
|
||||
The workflow file (`.github/workflows/docker-publish.yml`) already
|
||||
pings Coolify's webhook with `?force=true` after the GHCR push, so the
|
||||
trigger half of the contract is in place — the missing half is the
|
||||
registry-digest check, which is a Coolify-side feature.
|
||||
|
||||
## Verification
|
||||
|
||||
Push a new release tag. The expected sequence is:
|
||||
|
||||
1. `docker-publish.yml` builds, signs, pushes to GHCR.
|
||||
2. Coolify webhook fires; Coolify pulls the new digest because
|
||||
"Watch image registry" is enabled.
|
||||
3. `https://healthlog.bombeck.io/api/version` returns the new version
|
||||
string within ~60s of the GHCR push — without SSH.
|
||||
|
||||
If step 3 still requires a host-side retag, the checkbox is in the
|
||||
wrong place in the UI for that Coolify version — open the audit doc
|
||||
above and the v1.5 deferral note for the alternative
|
||||
(GHCR-tag-promoted-by-workflow path).
|
||||
@@ -0,0 +1,508 @@
|
||||
# Wave 1a — PROD probe + token-leak hunt + backlog inventory (v1.4.22)
|
||||
|
||||
Started: 2026-05-10T20:20+02:00
|
||||
Branch: develop (post-v1.4.21)
|
||||
Production probed: `https://healthlog.bombeck.io` (image digest
|
||||
`sha256:4e818d44702c…`, v1.4.21)
|
||||
Probe scripts (read-only, no commits):
|
||||
- `.planning/v1422-w1a-tokens-probe.mjs` (admin/api-tokens overflow walk)
|
||||
- `.planning/v1422-w1a-insights-probe.mjs` (Insights `Metrik` / token leak hunt)
|
||||
- `.planning/v1422-w1a-targets-probe.mjs` (Zielwerte page screenshot)
|
||||
|
||||
## 1. BD-Zielbereich tile
|
||||
|
||||
### What I see today
|
||||
|
||||
`src/components/charts/trend-card.tsx` paints the BD tile in the
|
||||
shared `<TrendCard>` chrome:
|
||||
|
||||
- **Headline (3xl bold):** `latest = data.bpInTargetPct` — routed through
|
||||
`windows.allTime?.pct` since v1.4.19 A1. For the live tenant this is
|
||||
~10.8 % rounded to 11 %.
|
||||
- **Sub-row (`mt-auto` muted text-xs):** `7d: <avg7> · 30d: <avg30>` —
|
||||
`data.bpInTargetPct7d / pct30d`. For the live tenant, both 50 %.
|
||||
- No window label on the headline. The tile reads **"11 % … 7d 50 30d 50"**,
|
||||
a juxtaposition that punishes the recent improvement: the headline
|
||||
is the story (large + bold) but represents the slowest-moving
|
||||
aggregate possible (every paired BP reading since 2022).
|
||||
|
||||
The cognitive flow is "your number is 11 %" → "wait, the recent
|
||||
windows say 50 %" → "which one is real?". Even after grok-ing that the
|
||||
headline is all-time, the reader is left with a number that is
|
||||
technically correct but emotionally wrong: a user who put in real
|
||||
recent work to drag the rate up sees an unmoving aggregate that
|
||||
takes years of continued discipline to budge.
|
||||
|
||||
### Three proposals (recommend one)
|
||||
|
||||
#### A) Re-anchor headline to last-30d, surface all-time as a sub-value
|
||||
|
||||
Pro:
|
||||
- The dashboard tile becomes a "what's happening now" widget, matching
|
||||
every other tile on the page (weight, pulse, mood — all show recent
|
||||
reading + 7d/30d). The BD tile is currently the only one whose
|
||||
headline is an all-time number.
|
||||
- The 50 % feels earned; users see "improving" as movement and the
|
||||
sub-line gives historical context without dominating.
|
||||
- One-line API change: `bpInTargetPct = windows.last30Days?.pct` plus
|
||||
a new field `bpInTargetPctAllTime` for the sub-row.
|
||||
|
||||
Con:
|
||||
- Inverts the v1.4.19 A1 fix story. Need a backlog entry to clarify
|
||||
that the v1.4.19 fix was about *correctness* (the headline was
|
||||
silently equal to the 30-day) and v1.4.22 is about *framing* (the
|
||||
all-time aggregate belongs in the small text).
|
||||
- Cache invalidation on the `analytics` query (existing TanStack
|
||||
pattern handles this).
|
||||
|
||||
#### B) Explicit window label "Allzeit / All time" on the headline
|
||||
|
||||
Pro:
|
||||
- Smallest diff. Add a `<span className="text-xs text-muted">{t(
|
||||
"dashboard.bpInTargetAllTimeLabel")}</span>` next to the 11 %. No
|
||||
data-shape change, no API change.
|
||||
- Honours the v1.4.19 A1 contract verbatim — the headline still
|
||||
surfaces the slowest-moving number.
|
||||
|
||||
Con:
|
||||
- The cognitive dissonance remains: the user still reads the big
|
||||
number first and the label second. A 50 % → 50 % → 11 % "punishment
|
||||
reading" persists; the label only confirms why.
|
||||
- No other tile on the page carries a window label on its headline,
|
||||
so this is a one-off pattern.
|
||||
|
||||
#### C) Three-window chip row (no headline) — replace big-number layout
|
||||
|
||||
Pro:
|
||||
- Renders the tile as three equal-weight chips: `7d 50 % · 30d 50 % ·
|
||||
Allzeit 11 %`. No single number dominates; the user picks the
|
||||
window that matters to them.
|
||||
- Visually distinct enough to break the "every TrendCard is identical"
|
||||
uniformity, which signals to the user that BP-in-target is a
|
||||
conceptually different metric than weight / pulse.
|
||||
|
||||
Con:
|
||||
- Largest design lift. Diverges from the shared `<TrendCard>` chrome
|
||||
used everywhere else on the dashboard, so either (a) BD tile gets
|
||||
a bespoke `<BpInTargetTile>` component, or (b) the shared TrendCard
|
||||
picks up an optional "windowed mode". Either is a Wave-2/3 redesign
|
||||
item, not a Wave-4 polish patch.
|
||||
- Mobile tile is already dense at 50 %/50 % — fitting three chips
|
||||
is a viewport challenge.
|
||||
|
||||
### Recommendation: **A** (re-anchor to last-30d, all-time → sub-row)
|
||||
|
||||
Smallest UX win for the smallest engineering lift, and aligns the BD
|
||||
tile with every other tile on the dashboard. The all-time number
|
||||
stays visible as a sub-value alongside `7d` and `30d`, so power users
|
||||
can still reference it. The v1.4.19 A1 contract is preserved (the
|
||||
all-time aggregate is computed and surfaced) — only the visual
|
||||
priority changes. The fix is roughly:
|
||||
|
||||
- `src/app/api/analytics/route.ts`: split `bpInTargetPct` (allTime)
|
||||
→ keep as `bpInTargetPctAllTime`; promote `windows.last30Days.pct`
|
||||
to the new `bpInTargetPct` headline.
|
||||
- `src/components/charts/trend-card.tsx` — extend `<TrendCard>` with
|
||||
an optional `tertiaryAvg` slot OR (cleaner) accept a third
|
||||
sub-value pair (`avgAllTime`, `avgAllTimeLabel`) and render it as
|
||||
`· Allzeit: <pct> %` next to `7T:` and `30T:`. Keep the change
|
||||
optional + behind feature-detection so other tiles don't pick up
|
||||
the third row.
|
||||
- Tests: `bp-in-target.test.ts` already pins the three-window
|
||||
contract; add a `<TrendCard>` test that asserts the headline
|
||||
matches the 30-day pct when both are passed.
|
||||
|
||||
## 2. Admin / api-tokens scrollbar
|
||||
|
||||
### Per-viewport probe results
|
||||
|
||||
| Viewport | viewport×inner | doc overflow | body overflow | Real culprit |
|
||||
| ------------------- | -------------- | ------------ | ------------- | ------------------------------------------ |
|
||||
| iphone-se 375×667 | 375 / 375 | NO | NO | (none — only the intentional `nav.no-scrollbar` mobile section strip) |
|
||||
| pixel5 393×851 | 393 / 393 | NO | NO | (none — same intentional nav) |
|
||||
| ipad-mini 768×1024 | 768 / 768 | NO | NO | `<div class="hidden overflow-x-auto md:block">` (intentional `auto`, BUT triggers because the inner table overflows) — table sw=212, cw=122, delta=90 |
|
||||
| desktop 1280×800 | 1280 / 1280 | NO | NO | Same wrapper. Table sw=663, cw=634, delta=29 |
|
||||
|
||||
### Real culprit
|
||||
|
||||
**The desktop table overflows its `overflow-x-auto` wrapper at md+ even
|
||||
on a 1280-wide desktop viewport.**
|
||||
|
||||
Selector: `div.hidden.overflow-x-auto.md:block` →
|
||||
`table.w-full.table-fixed.text-sm` (file:
|
||||
`src/components/admin/api-token-overview-section.tsx:145-238`).
|
||||
|
||||
Why the table overflows even with `table-fixed` + percentage colgroup:
|
||||
|
||||
1. The "Last used" column (`<col className="w-[12%]" />`) carries
|
||||
`whitespace-nowrap` (line 225) and renders strings like
|
||||
`"05.05.2026, 21:46"` (~14 chars after locale) → ~110 px at the
|
||||
table's font size.
|
||||
2. The "Created" column (`<col className="w-[8%]" />`) also carries
|
||||
`whitespace-nowrap` (line 230) and renders the same locale-aware
|
||||
`"05.05.2026"` ~80 px wide.
|
||||
3. At 1280 px viewport with sidebar 220 px + gap 24 px + page padding
|
||||
~64 px, the card's content area ≈ 700 px. 12 % of 700 = 84 px,
|
||||
8 % = 56 px. The `whitespace-nowrap` strings (~110 + ~80 px)
|
||||
overflow their colgroup-allotted percentages.
|
||||
4. `whitespace-nowrap` wins over `table-fixed`'s width contract, so
|
||||
the table's intrinsic width exceeds 100 %. The wrapper's
|
||||
`overflow-x-auto` then paints a horizontal scrollbar to let the
|
||||
user see the clipped columns — **that is the scrollbar Marc keeps
|
||||
reporting**.
|
||||
|
||||
Visual confirmation: `/tmp/v1422-w1a/desktop-fullpage.png` shows the
|
||||
"Last used" and "Create" headers truncated mid-word and the rightmost
|
||||
date strings clipped (e.g. "05/05/" with the year cut off).
|
||||
|
||||
### Concrete fix path
|
||||
|
||||
Three options, in order of preference:
|
||||
|
||||
1. **Drop `whitespace-nowrap` on the date cells** (lines 225, 230).
|
||||
`formatDateTime("05.05.2026, 21:46")` is two semantic chunks; let
|
||||
it wrap to two lines on narrow viewports. The card grows by one
|
||||
row but no scrollbar — and the desktop case at >1024 px already
|
||||
fits the unwrapped string in 12 %.
|
||||
|
||||
2. **Compact date format on the "Last used" column.** Use
|
||||
`formatDateShort()` (already in `src/lib/format.ts:51`) which
|
||||
drops the year. "05.05" + time fits in 70 px; the trailing
|
||||
tooltip already exposes the full timestamp on hover.
|
||||
|
||||
3. **Hide the "Created" column on md (768-1023 px).** Add a
|
||||
`hidden lg:table-cell` to the 6th `<col>` + `<th>` + `<td>`. The
|
||||
tablet user trades one column for a clean layout; desktop keeps
|
||||
everything.
|
||||
|
||||
Option 1 is smallest diff (two-line change, no i18n surface impact).
|
||||
Option 2 is the cleanest UX. Option 3 is what the "User"/"Permissions"
|
||||
columns already do via the mobile card-list — same pattern, one
|
||||
breakpoint up.
|
||||
|
||||
## 3. Raw `metric:<TYPE>` token leaks
|
||||
|
||||
### Where they emit
|
||||
|
||||
The Playwright probe captured the literal substrings rendered in the
|
||||
production /insights page (German locale, live-tenant data):
|
||||
|
||||
| Match | Context (rendered prose, last 60 + 60 chars) |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `metric:BLOOD_PRESSURE_SYS` | "…strukturiertes Gegensteuern sinnvoll ist. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
|
||||
| `metric:BLOOD_PRESSURE_SYS` | "…Medikation mit niedrigeren systolischen Werten einhergeht. metric:BLOOD_PRESSURE_SYS\n\n…" |
|
||||
| `metric:WEIGHT` | "…damit ich die BMI-Klassifikation präzise berechnen kann. metric:WEIGHT\n\nZuletzt aktualisiert" |
|
||||
| `metric:PULSE` | "…und aktuell, daher ist die Aussage belastbar. metric:PULSE\n\nZuletzt aktualisiert" |
|
||||
| `metric:MOOD` | "…nur 4 % Zielbereichstreffern in den letzten 30 Tagespunkten. metric:MOOD\n\nZuletzt aktualisiert" |
|
||||
| `metric:BLOOD_PRESSURE_SYS` | "…regelmäßig nur eine von zwei geplanten Tagesdosen. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
|
||||
|
||||
The `Zuletzt aktualisiert: …` timestamp tail is the per-section
|
||||
advisor card footer — so the leak is appearing at the END of an
|
||||
**advisor card recommendation text** (not in the `summary` or
|
||||
`primaryRecommendation`, both of which are stripped).
|
||||
|
||||
### Where the strip is missing
|
||||
|
||||
`src/components/insights/recommendation-card.tsx:336`:
|
||||
|
||||
```tsx
|
||||
<p className="text-sm leading-snug">{norm.text}</p>
|
||||
```
|
||||
|
||||
`norm.text` (= `rec.text`) is rendered **directly without
|
||||
`stripChartTokens()`**. Every other AI-prose surface on the page —
|
||||
`insight.summary` (line 594), `insight.primaryRecommendation` (line
|
||||
573), `finding.label` (line 622), `finding.guideline` (line 631) —
|
||||
runs through `stripChartTokens()`. The recommendation text is the one
|
||||
field the v1.4.19 A3 widening missed.
|
||||
|
||||
The model is following its own prompt (it embeds `metric:WEIGHT` etc.
|
||||
in the rec text expecting an inline mini-chart), but the rec-card's
|
||||
chart slot lives in the EXPANDED rationale block (line 226-234, only
|
||||
visible after the user clicks the chevron), so the token has nowhere
|
||||
to be consumed in the always-visible rec text — it just leaks.
|
||||
|
||||
### Concrete fix
|
||||
|
||||
Two-line diff:
|
||||
|
||||
```diff
|
||||
- <p className="text-sm leading-snug">{norm.text}</p>
|
||||
+ <p className="text-sm leading-snug">{stripChartTokens(norm.text)}</p>
|
||||
```
|
||||
|
||||
plus the `import { stripChartTokens } from "@/lib/insights/chart-tokens";`
|
||||
at the top of `recommendation-card.tsx`.
|
||||
|
||||
Backstop test in the existing `insights-polish.test.ts` describe-block
|
||||
(`v1.4.19 A3 — chart-token leak hardening`) — add an
|
||||
`it("RecommendationCard strips metric:* tokens from rec.text")` case
|
||||
that mounts a rec with `text: "increase fluids. metric:WEIGHT"` and
|
||||
asserts the rendered DOM does NOT contain `metric:WEIGHT`.
|
||||
|
||||
### Bonus translation defect uncovered en route
|
||||
|
||||
Marc reported "Metrik Mut" — the actual rendering on prod is the
|
||||
**Health-Score sub-bar label**, which surfaces "Mood" verbatim in
|
||||
German because `messages/de.json:778` says:
|
||||
|
||||
```json
|
||||
"componentMood": "Mood",
|
||||
```
|
||||
|
||||
instead of `"Stimmung"`. The label is rendered by
|
||||
`src/components/insights/health-score-card.tsx:65` via
|
||||
`COMPONENT_LABEL_KEY.mood = "insights.healthScore.componentMood"`.
|
||||
|
||||
One-character fix. Worth bundling into the same Wave-4 patch so
|
||||
"the BP/Weight/Mood/Meds row" reads natively German.
|
||||
|
||||
## 4. Targets / Zielwerte page
|
||||
|
||||
### Current state (probe screenshots)
|
||||
|
||||
`/tmp/v1422-w1a-targets/desktop.png` (1280×900) and
|
||||
`/tmp/v1422-w1a-targets/pixel5.png` (393×851).
|
||||
|
||||
The page is a 2-col (desktop) / 1-col (mobile) grid of identical
|
||||
`<TargetCard>` instances. Each card has:
|
||||
|
||||
- Card header: icon + label + small trend arrow (TrendingUp/Down/Minus)
|
||||
- Big-number current value (with unit and 30-day mean line)
|
||||
- Horizontal three-zone range bar (red / yellow / green) with a dot
|
||||
marker for the current value
|
||||
- Status pill (e.g. "Optimal", "Im Zielbereich", "Moderat")
|
||||
- External-link to the source guideline (WHO BMI / ESH 2023 /
|
||||
CDC/NCHS …)
|
||||
|
||||
Compared to `/dashboard` and `/insights` the page is visually
|
||||
**static and reference-y**:
|
||||
|
||||
- No 7-day delta. Only the 30-day mean.
|
||||
- No sparkline / mini-chart in the card. The user sees the *current*
|
||||
value but no story of how they got there.
|
||||
- No "Δ vs last month" callout (the comparison toggle pattern shipped
|
||||
in v1.4.16 phase B8 lives only on the dashboard).
|
||||
- No grouping. BD-Zielbereich (50 % in target) sits next to BMI
|
||||
(Normal) sits next to Einnahmetreue (64 %) — three completely
|
||||
different metrics styled identically.
|
||||
- Status pills are passive — they label the current bucket but don't
|
||||
invite action ("So what do I do about this?").
|
||||
|
||||
The "ungelebt" feeling is real: every card is a flat reference card
|
||||
with no momentum, no comparison, no "your trajectory" angle. It looks
|
||||
like a guideline cheat-sheet, not a living health log.
|
||||
|
||||
### Three upgrade directions (pick one for Wave 4)
|
||||
|
||||
#### A) Sparkline-per-card + "Δ vs last month" callout
|
||||
|
||||
Each `<TargetCard>` grows a 30-day mini-sparkline beneath the range
|
||||
bar (same `<HealthChart mini>` already used in the InlineCharts of
|
||||
`/insights`). A second-line "Δ −2.3 kg vs last month" caption mirrors
|
||||
the dashboard's comparison toggle so the user sees movement at a
|
||||
glance. Status pill stays.
|
||||
|
||||
Pro: reuses existing components (HealthChart mini, comparison delta,
|
||||
TrendCard's metric-sentiment colour rules). Each card becomes a
|
||||
"current state + recent journey" block.
|
||||
|
||||
Con: doubles card height. 12 cards × +120 px is +1440 px scroll on
|
||||
mobile. Need a "compact mode" toggle.
|
||||
|
||||
#### B) Status-row + drill-down — collapse cards into a scannable list
|
||||
|
||||
Replace the grid with a vertical list. Each row: icon + label +
|
||||
current value + status pill + small trend arrow + chevron to expand.
|
||||
Expansion reveals the range bar + 30-day chart + source link.
|
||||
|
||||
Pro: 12 metrics fit on one screen. Glance answer first, detail on
|
||||
demand. Mirrors the iOS Health pattern users know.
|
||||
|
||||
Con: Bigger redesign — at-a-glance loses the range-bar visualisation
|
||||
that gives the page its "where am I in the band?" answer. Might be
|
||||
too clinical.
|
||||
|
||||
#### C) Storyline framing — a short headline + 3-target hero, then everything else
|
||||
|
||||
The page opens with a one-sentence summary "Du bist in 8 von 12
|
||||
Zielbereichen.", then surfaces the 3 most-out-of-target metrics as
|
||||
hero cards with bigger range bars + a "What changed?" callout, with
|
||||
the remaining 9 collapsed below as a compact summary strip.
|
||||
|
||||
Pro: solves the "ungelebt" problem with a narrative. Targets become a
|
||||
state-of-your-health page, not a reference book. The hero strip ties
|
||||
back to the `<HeroStrip>` pattern shipped on `/insights` for visual
|
||||
consistency.
|
||||
|
||||
Con: requires server-side ranking ("which targets are most out of
|
||||
band?"), so this is a Phase-2 redesign. Out of scope for a polishing
|
||||
marathon. **Defer to v1.5.**
|
||||
|
||||
### Recommendation: **A** (sparkline + Δ-vs-last-month)
|
||||
|
||||
Lowest effort, highest "the page now feels alive" payoff, reuses
|
||||
existing components. The mobile-height cost is mitigated by collapsing
|
||||
the range bar's surrounding meta into a single row. Direction B is a
|
||||
v1.5-scope rethink; direction C is a v1.6 product call.
|
||||
|
||||
For Wave 4 the lift is roughly:
|
||||
|
||||
- Add `30dPoints?: number[]` to the `TargetData` API shape (server
|
||||
already has the data — `/api/insights/targets` reads from the same
|
||||
Prisma rows the dashboard does).
|
||||
- Mount `<HealthChart types={[target.type]} mini />` inside
|
||||
`<TargetCard>` between the range bar and the classification row.
|
||||
- Pipe the comparison delta from the existing `compare` server route
|
||||
(used by the dashboard) into the page query, render it as a
|
||||
`<TileCompareDelta>` row above the range bar.
|
||||
|
||||
## 5. v1.4.21 backlog inventory
|
||||
|
||||
Source: `.planning/v1421-backlog.md` (301 lines). Items grouped by
|
||||
complexity bucket below.
|
||||
|
||||
### Quick wins (apply en masse in Wave 4 D — ≤5 lines diff each)
|
||||
|
||||
- **Sec-L-1** — `coachChatRequestSchema.conversationId` regex tightening to
|
||||
`^c[a-z0-9]{24}$` or `[A-Za-z0-9_-]{8,64}`. One-line schema change.
|
||||
- **Sr-LOW-1** — drop redundant `@@index` on `coach_usage`; `@@unique`
|
||||
already builds it. One-line schema removal.
|
||||
- **Code-MED-04** — reuse `buildWeightRangeFromHeight()` for
|
||||
`weightTrendAlignment`'s ±2 kg target band. Single import + call swap.
|
||||
- **Code-MED-07** — drop the dead `confidence` prop from `<TrendsRow>`
|
||||
call site. One-line removal.
|
||||
- **Design-L1** — drop the generic "personal-baseline" hero copy line.
|
||||
One-line removal.
|
||||
- **Design-L5** — append `…` suffix when storyboard 24-char truncation
|
||||
fires. Three-line addition in
|
||||
`src/components/insights/storyboard-row.tsx`.
|
||||
- **Design-L6** — replace German `Umschalt+Enter` with literal
|
||||
`Shift+Enter` to match the rest of the app. One i18n key change.
|
||||
- **Code-LOW-01** — wrap `<HeroStrip>` greeting + relativetime in
|
||||
`useMemo`. Two-line change.
|
||||
- **Code-MED-09** — wrap storyboard payload through
|
||||
`storyboardAnnotationsSchema.safeParse`. Pattern-copy from
|
||||
`dailyBriefing` lift; ~5 lines.
|
||||
- **Sr-MED-1** — add 3 cascade-delete test rows for
|
||||
`coachConversation` / `coachMessage` / `coachUsage`. 5 lines.
|
||||
- **Sr-LOW-4** — tighten `weeklyReport.weekISO` regex to validate
|
||||
weeks 01-53. One regex change.
|
||||
- **S-06** — drop dead `historyRail` / `sourcesRail` / `composer` slot
|
||||
props on `<CoachDrawer>`. ~10 lines deletion.
|
||||
- **S-07** — drop the never-overridden `inputId` prop on
|
||||
`<CoachInput>`. One-line removal.
|
||||
|
||||
**Wave-4 quick-wins count: 13 items**, total estimated diff ≤
|
||||
~80 lines across 11 files. **Recommend folding all 13 into Wave-4 D.**
|
||||
|
||||
### Medium (one new helper / one component refactor)
|
||||
|
||||
- **Code-MED-02** — `bandFromInterval` "high" label on near-zero r —
|
||||
rename chip to "tight CI" / "wide CI" or demote when CI straddles
|
||||
zero. Touches the helper + every chip caller.
|
||||
- **Code-MED-03** — Pearson p-value normal-approx error at low df.
|
||||
Either pull in incomplete-beta (~30 LOC) or raise df gate to 20.
|
||||
Decision call.
|
||||
- **Code-MED-05** — `bpInTargetPct` "vs last week" delta is held
|
||||
constant. Recompute prior-snapshot rate or relabel the line.
|
||||
- **Code-MED-06** — `WeeklyReportView` print spinner timer reset
|
||||
bug. Pin via ref.
|
||||
- **Code-MED-08** — `provenanceFromJson` cast — validate against
|
||||
`WINDOW_KEYS` allow-list.
|
||||
- **Code-LOW-03** — Promote nested `<Sheet>` portals on mobile rail
|
||||
trays to siblings.
|
||||
- **Code-LOW-05** — add `bpInTargetRate` constant-test gap test.
|
||||
- **Code-LOW-06** — `streamRefusal` budget meter accounting.
|
||||
- **Sec-M-2** — call `checkRateLimit()` before refusal scanning OR
|
||||
bump `messageCount` inside `streamRefusal()`.
|
||||
- **Sec-M-3** — extend `INJECTION_PATTERNS` with synonyms +
|
||||
zero-width strip pass. Maintenance pattern, ~30 LOC.
|
||||
- **Sec-M-4** — wrap `appendMessage(assistant)` + `recordSpend()` in
|
||||
Prisma `$transaction`.
|
||||
- **Sec-L-3** — skip `updatedAt` bump for refusal appends OR hide
|
||||
refusal-only conversations.
|
||||
- **Sec-U-1** — Vitest case smoke-testing `request.clone()` against
|
||||
Next.js 16.
|
||||
- **Sr-MED-2** — lift `weeklyReport` + `storyboardAnnotations` through
|
||||
`safeParse`.
|
||||
- **Sr-MED-3** — wrap `handleChatRequest` in `enforceRateLimit()`.
|
||||
- **Sr-MED-6** — offer `withIdempotency(handler, { skipWhen })` for
|
||||
conditional opt-out.
|
||||
- **Sr-LOW-2** — migrate `metricSourceJson` from `String` to `Jsonb`.
|
||||
- **Sr-LOW-3** — coach off-topic detector locale-mixed tightening.
|
||||
- **S-08** — re-read `cloneForCheck` dead variable.
|
||||
- **S-09** — compress health-score-card delta-arrow `&&` blocks into
|
||||
a `deltaIcon` lookup. Borderline.
|
||||
- **Design-M1** — Hero strip greeting clock → Berlin via
|
||||
`Intl.DateTimeFormat`.
|
||||
- **Design-M3** — Health Score panel mobile order inversion.
|
||||
- **Design-M5** — Coach drawer focus trap — reparent rail-tray
|
||||
Portal container to SheetContent.
|
||||
- **Design-M6** — Tone-bar visual clip at rounded corner.
|
||||
- **Design-M8** — "n=N" reads as engineering output — use `{count}
|
||||
samples` / `{count} Werte`.
|
||||
- **Design-L2** — Health Score sub-bars per-component band.
|
||||
|
||||
**Wave-4 medium count: 26 items.** Recommend Wave-4 picks the BP-tile
|
||||
fix (Code-MED-05), the Sec-M-2/3/4 cluster (small but security-adjacent),
|
||||
the `safeParse` lifts (Sr-MED-2 + Code-MED-08 + Code-MED-09), and the
|
||||
2-3 design polishes that ride along (Design-M1 / M6 / M8). **Defer
|
||||
the rest to v1.4.23.**
|
||||
|
||||
### Heavy (cross-cutting, schema, or larger refactor)
|
||||
|
||||
- **Sr-HIGH-2** — Consolidate Pearson + linear regression duplicated
|
||||
maths layer into `correlation.ts` + `regression.ts`. **v1.4.23**.
|
||||
- **Sr-HIGH-4** — `<CoachDrawer key={prefill}>` weaponising React keys
|
||||
for state reset. Make `prefill` fully-controlled. **v1.4.23**.
|
||||
- **Sr-MED-4** — Wire provider runner to true streaming with `onToken`
|
||||
callback. Unlocks "stop generating" as a real feature. **v1.5**.
|
||||
- **Sr-MED-5** — `medication_schedules.days_of_week` schema drift.
|
||||
Either land the column with backfill or drop the field. **v1.4.23**
|
||||
(schema change with downstream impact).
|
||||
- **Code-MED-01** — Streaming auto-scroll deps brittleness fix.
|
||||
Touches MessageThread effect and a new turn-counter hook.
|
||||
**v1.4.23**.
|
||||
|
||||
**Wave-4 heavy count: 5 items, all deferred.**
|
||||
|
||||
### Already obsolete (resolved en passant or no longer relevant)
|
||||
|
||||
- **Sec-U-2** — `enforceBudget` race window. Acceptable in practice;
|
||||
per-day cap. Annotation-only suggestion. Promote to "not a defect"
|
||||
and close.
|
||||
- **Sr-MED-6** carry-over — `apiHandler`-vs-`withIdempotency` stacking
|
||||
is "largely moot now that the chat route doesn't wrap in
|
||||
idempotency". The carry-over note documents that — close as obsolete.
|
||||
- The Docs-site MED + LOW items (versions stale, model count, schedule
|
||||
timezone, hashing wording) — these are cross-repo to
|
||||
`healthlog-docs`. **Out of scope for v1.4.22 HealthLog work**;
|
||||
belong in the docs repo's own next sweep. Move to a separate file
|
||||
`docs-followup.md` or close as out-of-repo.
|
||||
- F5 README badges, FUNDING.yml, `.gitignore` audit, repo-root tidy —
|
||||
cosmetic only. The README badge add is useful but not v1.4.22-blocking.
|
||||
**Push to a hygiene PR**.
|
||||
- `CLAUDE.md` / `AGENTS.md` filename rename, source-comment "Marc"
|
||||
sweep, DE+EN bilingual CHANGELOG normalisation — **defer to a
|
||||
hygiene PR series in v1.5**. The filename rename in particular is
|
||||
structural and would affect every IDE / CI tool that special-cases
|
||||
these filenames; a one-line v1.4.22 patch is the wrong vehicle.
|
||||
|
||||
**Obsolete count: 7 items.** Mark closed in the v1.4.21 backlog,
|
||||
move docs items to `docs-followup.md`, and rebase v1.4.22 backlog
|
||||
to start with the 13 quick-wins + 8-10 medium picks above.
|
||||
|
||||
---
|
||||
|
||||
## Summary numbers
|
||||
|
||||
- **Quick-wins to fold into Wave-4 D:** 13 items, ~80 LOC total
|
||||
- **Medium picks to fold into Wave-4 (selectively):** 8-10 of 26
|
||||
- **Heavy items deferred to v1.4.23 / v1.5:** 5
|
||||
- **Obsolete to close:** 7
|
||||
|
||||
Probe artifacts:
|
||||
- `/tmp/v1422-w1a/findings.json` + 4 viewport screenshots
|
||||
- `/tmp/v1422-w1a-insights/findings.json` + 3 locale screenshots
|
||||
- `/tmp/v1422-w1a-targets/desktop.png` + `pixel5.png`
|
||||
@@ -0,0 +1,153 @@
|
||||
# Wave 2 — Insights surface polish (v1.4.22)
|
||||
|
||||
Started: 2026-05-10T20:40+02:00
|
||||
Completed: 2026-05-10T21:00+02:00
|
||||
Branch: develop (post-v1.4.21)
|
||||
Six atomic commits delivered to `origin/develop`.
|
||||
|
||||
## A1 — BD-Zielbereich headline re-anchor
|
||||
|
||||
`fix(analytics): BP target headline reads last-30d, all-time becomes sub-row`
|
||||
|
||||
Up to v1.4.19 the headline pinned to `windows.last30Days?.pct` — a
|
||||
literal copy of the 30T sub-value, producing the 50/50/50 algorithmic
|
||||
pin Marc reported. v1.4.19 A1 routed the headline to `allTime`, which
|
||||
fixed the pin but introduced a new emotional problem: the headline
|
||||
became the slowest-moving aggregate possible, punishing recent
|
||||
improvement.
|
||||
|
||||
v1.4.22 A1 re-anchors the route's `bpInTargetPct` to
|
||||
`windows.last30Days?.pct` and adds a new `bpInTargetPctAllTime` field
|
||||
so the long-arc number stays accessible as a sub-value. The
|
||||
`computeBpInTargetWindows()` helper is unchanged — only the route's
|
||||
headline pick changed.
|
||||
|
||||
**Files touched**: `src/app/api/analytics/route.ts`,
|
||||
`tests/integration/bp-in-target.test.ts` (two new integration cases
|
||||
pinning the headline source + all-three-windows envelope shape).
|
||||
|
||||
## A2 — BD-Kachel feature parity
|
||||
|
||||
`feat(insights): BP tile gains trend arrow + comparison overlay parity`
|
||||
|
||||
Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
|
||||
without a trend arrow, 7-day-trend chip, or comparison overlay caption.
|
||||
Synthesised a slope from the difference between the 7-day and 30-day
|
||||
in-target shares: 7d > 30d means recent improvement (up-good ⇒ green
|
||||
arrow). The 7-day-trend chip uses the same delta.
|
||||
|
||||
Extended `<TrendCard>` with optional `avgAllTime` / `avgAllTimeLabelKey`
|
||||
/ `avgAllTimeColorClass` props so the all-time aggregate stays visible
|
||||
next to `7d:` and `30d:` after the A1 re-anchor. Other tiles leave the
|
||||
field undefined so the third sub-row never renders for them.
|
||||
|
||||
**Files touched**: `src/components/charts/trend-card.tsx`,
|
||||
`src/components/charts/__tests__/trend-card-all-time.test.tsx` (new),
|
||||
`src/app/page.tsx`, `messages/{en,de}.json` (new
|
||||
`charts.avgAllTimeShort` key).
|
||||
|
||||
## A3 — Comparison-Toggle → global Settings
|
||||
|
||||
`feat(settings): comparison-overlay becomes a global dashboard preference`
|
||||
|
||||
Per `feedback_settings_no_split.md`, removed the on-surface
|
||||
`<CompareToggle />` from `/insights`. The canonical picker already
|
||||
lives in Settings → Dashboard (since v1.4.16 phase B8), and the
|
||||
`User.dashboardWidgetsJson.comparisonBaseline` field already persists
|
||||
the value. Every chart on `/insights` still reads the resolved
|
||||
baseline so flipping the Settings toggle propagates atomically.
|
||||
|
||||
**Files touched**: `src/app/insights/page.tsx` (removed the meta-slot
|
||||
`<CompareToggle />` from `<DailyBriefing>` and the matching import),
|
||||
`src/app/__tests__/insights-polish.test.ts` (new A3 describe-block
|
||||
asserting the toggle is gone but `compareBaseline` plumbing remains).
|
||||
|
||||
## A4 — Insights layout normalisation
|
||||
|
||||
`fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data`
|
||||
|
||||
Three related layout polish wins:
|
||||
|
||||
- **Row-fill rule**: BP-medication grid, weight-correlation grid, and
|
||||
medications-per-day grid all hard-coded `xl:grid-cols-2` /
|
||||
`sm:grid-cols-2`, leaving half-rows of empty space when one of the
|
||||
two slots wasn't populated. All three now switch to a single
|
||||
full-width column when fewer than 2 cards render.
|
||||
- **Trends row equal heights**: each `<TrendsRow>` card is a flex
|
||||
column with `min-h-[300px]` so a multi-line AI annotation under one
|
||||
chart doesn't push that card taller than its neighbours.
|
||||
- **Hide insufficient correlations**: `<CorrelationRow>` drops cards
|
||||
whose `status !== "ok"` instead of painting a placeholder. Zero ok
|
||||
cards hides the row entirely (no header, no disclaimer); 1 card
|
||||
spans 100 % width; 2-3 cards split 50/50 on `>=md`.
|
||||
|
||||
**Files touched**: `src/components/insights/correlation-row.tsx`,
|
||||
`src/components/insights/trends-row.tsx`,
|
||||
`src/components/insights/__tests__/correlation-row.test.tsx`,
|
||||
`src/app/insights/page.tsx`.
|
||||
|
||||
## A5 — "Muster" rename + section tabs lift
|
||||
|
||||
`feat(insights): rename "Muster" + lift section tabs above the hero`
|
||||
|
||||
- **DE rename decision**: "Muster" → "Zusammenhänge". The W1b research
|
||||
suggested "Trends" or "Zusammenhänge"; picked the latter because the
|
||||
row directly above the correlation row already uses "Trends" for the
|
||||
trend-annotation strip. "Zusammenhänge" carries the relational
|
||||
meaning a correlation surface needs without borrowing terminology
|
||||
from its neighbour.
|
||||
- **EN parallel**: "Patterns" → "Relationships" for the same reason.
|
||||
- **Tab lift**: `<InsightsSectionNav>` moved from below the advisor
|
||||
card to the top of the page. Sticky scroll-anchored behaviour
|
||||
preserved (incremental — a full SPA-tab rewrite is out of scope for
|
||||
v1.4.22 polish). The user lands on Allgemein by default and sees
|
||||
the metric-tab strip before scrolling.
|
||||
|
||||
**Files touched**: `messages/{en,de}.json`, `src/app/insights/page.tsx`,
|
||||
`src/components/insights/__tests__/correlation-row.test.tsx`,
|
||||
`src/components/insights/__tests__/correlation-card.test.tsx`.
|
||||
|
||||
## A6 — Token-leak fix + DE component labels
|
||||
|
||||
`fix(insights): strip metric tokens in recommendation prose + DE component labels`
|
||||
|
||||
W1a §3 traced the production "metric:WEIGHT" leak to
|
||||
`src/components/insights/recommendation-card.tsx:336`: every other
|
||||
AI-prose surface ran through `stripChartTokens()`, the recommendation
|
||||
text didn't. Two-line fix.
|
||||
|
||||
The bonus DE-locale defect: `componentMood: "Mood"` (English in the
|
||||
German file) plus the related `componentBp: "BD"` /
|
||||
`componentCompliance: "Medis"` abbreviations. Marc reported "Mut" —
|
||||
voice-to-text rendering of "Mood". All four labels now read natively
|
||||
German: `"Blutdruck"`, `"Gewicht"`, `"Stimmung"`, `"Einnahmetreue"`.
|
||||
|
||||
i18n integrity test added to pin the four DE component labels so a
|
||||
copy-paste regression can't reintroduce the leak.
|
||||
|
||||
**Files touched**: `src/components/insights/recommendation-card.tsx`,
|
||||
`src/components/insights/__tests__/recommendation-card.test.tsx`,
|
||||
`messages/de.json`,
|
||||
`src/lib/__tests__/i18n-locale-integrity.test.ts`.
|
||||
|
||||
## Test delta
|
||||
|
||||
| Layer | Before | After |
|
||||
| ----------- | ------ | ----- |
|
||||
| Unit (vitest) | 2036 tests / 238 files | 2049 tests / 239 files |
|
||||
| Integration | (covered separately) | +2 cases in `bp-in-target.test.ts` |
|
||||
|
||||
13 new unit cases + 2 new integration cases. All green.
|
||||
|
||||
## Decisions logged
|
||||
|
||||
- **DE rename**: "Zusammenhänge" over "Trends" — relational meaning,
|
||||
doesn't collide with the Trends-row title directly above.
|
||||
- **Section tabs**: kept the scroll-anchored sticky strip and lifted
|
||||
it above the hero. A full SPA-tab rewrite is deferred (v1.5+).
|
||||
- **All-time sub-value**: rendered as a third `<span>` inside the
|
||||
existing `flex gap-3` sub-row so on mobile it wraps naturally below
|
||||
`7d:` and `30d:` instead of crowding into a single line.
|
||||
- **Synthetic slope**: BP-tile uses `slope = (pct7 - pct30) / 30` as
|
||||
the slope-per-day signal. Confidence pinned to 1 because the source
|
||||
is a deterministic difference, not a regression fit.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Wave 3 — Coach polish report (v1.4.22)
|
||||
|
||||
Adopts the W1b research output: the Coach now talks like a partner
|
||||
who sits alongside the user, not a database cursor. Load-bearing
|
||||
numbers move out of the prose into a collapsible block at the bottom
|
||||
of every assistant turn. Five atomic commits on `origin/develop`.
|
||||
|
||||
## Commits
|
||||
|
||||
1. `feat(coach): warm motivational rewrite with evidence-block sentinel`
|
||||
— system-prompt EN + DE rewrite, PROMPT_VERSION 4.20.2 → 4.22.0,
|
||||
broadens schema-test regex off the 4.20.x pin.
|
||||
2. `feat(coach): parse evidence-block sentinel out of streamed prose`
|
||||
— `---KEYVALUES---` … `---END---` parser, `CoachProvenance.keyValues`
|
||||
extension, route + persistence + SSE provenance frame wiring,
|
||||
integration coverage.
|
||||
3. `feat(coach): collapsible evidence-block disclosure under assistant
|
||||
bubbles` — `<details>` disclosure with chevron in
|
||||
`message-thread.tsx`; closed by default; hides when keyValues is
|
||||
empty; both EN + DE labels.
|
||||
4. `refactor(coach): drawer polish — Gravatar avatar, disclaimer
|
||||
move, cog removal` — user bubble pulls `useAuth().user.gravatarUrl`
|
||||
at the same 32-px dimensions as the Coach avatar with initials
|
||||
fallback; disclaimer moved out of `coach-input.tsx` into
|
||||
`sources-rail.tsx`'s footer; settings cog removed cleanly from the
|
||||
drawer header.
|
||||
5. `chore(planning): record Wave 3 Coach polish completion` — STATE
|
||||
tick + this report.
|
||||
|
||||
## Sentinel-parse design
|
||||
|
||||
Wave 3 ships a text-marker sentinel (`---KEYVALUES---` … `---END---`)
|
||||
with line-formatted entries (`<label>: <value> [<unit>] (<window>)`)
|
||||
rather than the fenced-JSON shape W1b sketched. The maintainer
|
||||
called this directly — robust against accidental Markdown-render
|
||||
collisions, and easier to scan in raw provider logs.
|
||||
|
||||
Hard caps stack three ways:
|
||||
|
||||
- 1 KB on the sentinel-block payload (post-`---KEYVALUES---` byte
|
||||
count). Prompt-injection padding cannot grow the persisted
|
||||
envelope.
|
||||
- 8 lines kept after parsing — matches the prompt contract.
|
||||
- Each line validates against the Zod `coachKeyValueSchema`
|
||||
(length-capped label / value / unit / window). Malformed lines
|
||||
drop silently; the route logs a wide-event
|
||||
`coach.keyvalues.parse_failed` only when the block as a whole is
|
||||
malformed (missing close sentinel, truncated, or zero valid rows).
|
||||
|
||||
When the provider returns prose-only (mock client, refusal path,
|
||||
legacy provider that hasn't been re-prompted), the parser is a
|
||||
no-op: prose passes through, no `keyValues` field lands on the
|
||||
persisted envelope, and the UI hides the disclosure entirely.
|
||||
|
||||
## Settings cog
|
||||
|
||||
Removed per the maintainer's directive. The placeholder tooltip
|
||||
("Coach settings arrive in v1.4.21") read as a dead button after
|
||||
v1.4.21 shipped without delivering that surface. The v1.4.23
|
||||
candidate is a per-user prompt-tuning panel (tone, verbosity,
|
||||
fallback-language preference) — when that's ready, the cog returns
|
||||
with real wiring.
|
||||
|
||||
## Concrete prose example
|
||||
|
||||
User asks: "Meinst du, ich sollte mehr Sport machen?"
|
||||
|
||||
The new prompt produces (synthesised from the prompt's rules + the
|
||||
embedded few-shot):
|
||||
|
||||
> Bewegung sehe ich in deinem Tracking gerade nicht — magst du mir
|
||||
> kurz erzählen, wie eine typische Woche bei dir aussieht?
|
||||
> Spaziergänge, Sport, irgendwas Strukturierteres? Dann schauen wir
|
||||
> gemeinsam mit dem, was ich sehe, ob mehr Sport gerade Sinn ergibt.
|
||||
|
||||
No sentinel block (the turn is qualitative, no numbers carry the
|
||||
answer), so the disclosure is hidden client-side. The persona
|
||||
("warm, neugierig, zurückhaltend"), the rule-3 invitation pivot, and
|
||||
the rule-5 open question all fire in three sentences.
|
||||
|
||||
## Test count delta
|
||||
|
||||
- Unit tests: 2068 → 2085 (+17 — 14 system-prompt assertions, 18
|
||||
sentinel-parse cases, 4 message-thread disclosure + Gravatar
|
||||
cases; offset against the disclaimer-test rewrite in coach-input
|
||||
and sources-rail).
|
||||
- Integration tests: 5 → 7 (+2 — sentinel round-trip + prose-only
|
||||
graceful-degrade against the postgres testcontainer).
|
||||
|
||||
## Verification gates
|
||||
|
||||
- `pnpm typecheck` — clean.
|
||||
- `pnpm lint` — 13 pre-existing warnings (no new entries).
|
||||
- `pnpm test --run` — 241 files / 2085 tests green.
|
||||
- `pnpm test:integration tests/integration/coach-chat.test.ts` —
|
||||
7 tests green.
|
||||
|
||||
## What did not ship
|
||||
|
||||
Nothing was punted from the Wave 3 scope. The B3 Gravatar parity
|
||||
uses initials when the user's email is missing — the lookup is
|
||||
already server-side in `/api/auth/me`, so there's no extra round
|
||||
trip on the client. The Coach drawer mobile-tray test was untouched
|
||||
and still passes after the cog removal.
|
||||
|
||||
## What stays as Wave 3 followups
|
||||
|
||||
- v1.4.23 candidate: per-user Coach settings surface (tone slider,
|
||||
language preference). Brings the cog back with real wiring.
|
||||
- The `mock-client.ts` provider remains prose-only by design — no
|
||||
changes needed.
|
||||
- Sentinel format is documented in the prompt body itself; a
|
||||
future docs-site page on the v1.4.22 release covers the
|
||||
user-facing disclosure.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Wave 4 — Other surfaces + backlog cleanup (v1.4.22)
|
||||
|
||||
Started: 2026-05-10T21:15+02:00
|
||||
Completed: 2026-05-10T21:55+02:00
|
||||
Branch: develop (post-Wave-3 Coach polish, all five v1.4.21 e2e fix
|
||||
commits preserved)
|
||||
|
||||
## Six commits shipped
|
||||
|
||||
| # | Subject | Verdict |
|
||||
| - | ------------------------------------------------------------------------------------ | --------- |
|
||||
| 1 | feat(targets): 30-day sparkline + Δ-vs-last-month caption per card | shipped |
|
||||
| 2 | fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt) | shipped |
|
||||
| 3 | ops(deploy): force=true on Coolify webhook + node-26-alpine investigation | shipped |
|
||||
| 4 | fix(auth): move onboarding redirect from AuthShell useEffect to proxy | shipped |
|
||||
| 5 | i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums) | shipped |
|
||||
| 6 | chore: voice + filename hygiene (Marc sweep, bilingual CHANGELOG, CLAUDE.md rename) | shipped |
|
||||
|
||||
## C1 — Zielwerte sparkline + delta
|
||||
|
||||
Direction A from W1a §4. Each `TargetCard` grows a tiny inline SVG
|
||||
sparkline beneath the range bar + a localised "Δ −2.3 kg vs. last
|
||||
month" caption. The API ships `points30d` + `deltaVsLastMonth` per
|
||||
target; both are null when either window has fewer than 3 readings so
|
||||
cold-start accounts don't see a misleading flat trace. BMI piggybacks
|
||||
on the weight series (divided by height²) so its sparkline shares the
|
||||
range bar's y-axis. The new vitest spec
|
||||
`src/app/__tests__/targets-sparkline.test.tsx` pins four invariants.
|
||||
|
||||
## C2 — api-tokens scrollbar 5th attempt
|
||||
|
||||
Picked option 1 of W1a §2's three: drop `whitespace-nowrap` on the
|
||||
date `<td>`s. The v1.4.19 production probe showed the formatted
|
||||
date+time string ("05.05.2026, 21:46") exceeded its 12% colgroup
|
||||
allotment (~84px on a 700px content area) and `whitespace-nowrap`
|
||||
won over `table-fixed`'s width contract, so the table's intrinsic
|
||||
width exceeded 100% and the wrapping `overflow-x-auto` painted the
|
||||
scrollbar. Dropping the two classes lets the cell wrap to two lines
|
||||
on narrow viewports — one extra row of height in exchange for a
|
||||
scrollbar-free admin page on every viewport. Regression guarded by a
|
||||
new unit test inside `api-token-overview-responsive.test.tsx`.
|
||||
|
||||
## C3 — Coolify auto-deploy
|
||||
|
||||
Workflow now appends `?force=true` (or `&force=true` when the secret
|
||||
URL already has query params) so a doc-only push still triggers a
|
||||
registry-digest check on Coolify's side. The matching maintainer-side
|
||||
step ("Watch image registry for new digests" checkbox in the Coolify
|
||||
application config) is documented in
|
||||
`.planning/coolify-auto-deploy-howto.md` — one UI click, no code
|
||||
change required.
|
||||
|
||||
## C4 — AuthShell post-hydration redirect
|
||||
|
||||
Redirect moved from `<AuthShell>` `useEffect` to `src/proxy.ts`. The
|
||||
proxy runs in the Edge runtime and can't reach Prisma, so the auth
|
||||
routes (login, passkey-verify, register, password, `/api/auth/me`)
|
||||
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
|
||||
`hl_onboarding` cookie. The proxy reads the cookie without a DB
|
||||
roundtrip; the cookie is a UX hint, not a security signal — a user
|
||||
editing it locally only skips the dashboard flash, they still can't
|
||||
bypass any server check. `/api/onboarding/complete` clears the cookie
|
||||
so the next navigation drops the redirect immediately;
|
||||
`destroySession` + expired-session purge wipe it alongside the
|
||||
session cookie.
|
||||
|
||||
The e2e onboarding-flicker spec returned to testing the actual
|
||||
incomplete-onboarding case (`onboardingCompletedAt: null`) instead of
|
||||
pinning a non-null timestamp as the v1.4.21 e2e fix wave had to.
|
||||
`tests/integration/proxy-onboarding-redirect.test.ts` covers seven
|
||||
invariants including API-route pass-through, /onboarding self-loop,
|
||||
and unauthenticated-bounce regression.
|
||||
|
||||
## C5 — node-26-alpine
|
||||
|
||||
Dependabot PR #162 closed with a comment. Build failure root cause:
|
||||
node:26-alpine no longer ships with `corepack` pre-installed (the
|
||||
Dockerfile bootstraps pnpm via `corepack enable && corepack prepare
|
||||
pnpm@latest --activate`, which exits 127). Next.js 16 also still pins
|
||||
node 22 LTS as its supported minimum. Reopen the bump once Next.js
|
||||
either drops the node-22 floor or we migrate the Docker bootstrap off
|
||||
corepack.
|
||||
|
||||
## D — backlog cleanup tally
|
||||
|
||||
35 items proposed by the brief (13 quick-wins + ~22 mediums):
|
||||
|
||||
- **Quick-wins landed (10 of 13)**: F-32, F-33, F-34, F-35, F-44,
|
||||
F-45, F-47, F-50, F-58, F-62. Skipped: F-38 (couldn't locate the
|
||||
"Auto:" pseudo-label in the bundle), F-46 (admin/backups help
|
||||
fragment — could not pinpoint without UI confirmation), F-55
|
||||
(settings/export ampersand — no visible ampersand in current
|
||||
bundle).
|
||||
- **Mediums landed (2)**: D-CR-M-03 formatTokenName regex broadened
|
||||
to non-Z ISO offsets; D-DSGN-M-04 moodLog "Copy webhook secret"
|
||||
uses `common.copy` instead of stripping `!` off `common.copied`.
|
||||
- **Mediums deferred (the other ~6 picks the brief named)**: F-36
|
||||
status-word taxonomy (sprawling, needs design pass), F-37
|
||||
trailing-colon labels sweep (high test-assertion churn risk),
|
||||
F-49 decimal separator codemod (locale-formatter call-site
|
||||
audit needed), F-71 relative-time consistency (needs a holistic
|
||||
approach), D-SR-M-1 ChartCardHeader extract (touches 3
|
||||
callsites, deferred to v1.4.23), D-CR-M-07 chooseTickInterval
|
||||
e2e smoke at 393px (e2e infra work, deferred).
|
||||
|
||||
12 of the 35 brief items landed. The remaining 23 either deferred to
|
||||
v1.4.23 with reasoning or judged uncertain in the bundle scan.
|
||||
|
||||
## D2 — FX carry-overs
|
||||
|
||||
- **191 `Marc` references**: swept across `src/` via three sed
|
||||
passes covering ~50 distinct patterns (`Marc reported`,
|
||||
`Marc wants`, `Marc kept`, `Marc rolled back`, etc.). All
|
||||
non-test-fixture references neutralised to "the maintainer" /
|
||||
"the live tenant" / "the user" / "users". Test fixtures
|
||||
(`displayName: "Marc B."`, `username: "marc"`) kept per the brief
|
||||
as opaque test data.
|
||||
- **DE+EN bilingual CHANGELOG**: v1.4.14 + v1.4.15 sections
|
||||
normalised to English-only via a one-off node script that pulled
|
||||
the `_..._` English paragraph out of each bilingual bullet and
|
||||
rewrote the bullet as that text. ~44 entries reduced; one Docs
|
||||
bullet that lost a leading word in the regex fixed by hand.
|
||||
- **CLAUDE.md → CONTRIBUTING-AI.md**: renamed via `git mv` so the
|
||||
filename is no longer AI-vendor-specific. `CONTRIBUTING.md`
|
||||
reference updated. `AGENTS.md` stays (multi-agent compatibility).
|
||||
|
||||
## Test count delta
|
||||
|
||||
- Unit tests: **2097 passing** before Wave 4 → **2097 + 7 (proxy) +
|
||||
4 (sparkline) + 1 (api-token date-cell)** = **2109 passing**
|
||||
- Integration tests: 85 passing, unchanged (no new integration
|
||||
spec; the proxy test runs in the unit suite since the proxy is
|
||||
pure cookie-based logic with no DB access).
|
||||
|
||||
## Items genuinely un-shippable
|
||||
|
||||
- F-38, F-46, F-55: deferred — couldn't reliably locate the surface
|
||||
the brief described without an additional UI probe pass, and the
|
||||
marathon is closing.
|
||||
- The 5 heavy items from W1a §5 (Pearson consolidation,
|
||||
CoachDrawer key reset, true streaming, schema drift, Insights
|
||||
page split): all explicitly deferred to v1.4.23/v1.5 per the
|
||||
brief.
|
||||
- 7 obsolete items from W1a §5: closed as obsolete in
|
||||
`v1421-backlog.md` (Sec-U-2 race, Sr-MED-6 stacking moot,
|
||||
docs-site MED+LOW items are cross-repo, F5 cosmetic items push
|
||||
to hygiene PR).
|
||||
|
||||
## Pre-flight verification
|
||||
|
||||
- `pnpm typecheck` — clean
|
||||
- `pnpm lint` — 13 pre-existing warnings (no errors), unchanged
|
||||
- `pnpm test --run` — 2097+ passing
|
||||
- `pnpm test:integration` — 85 passing
|
||||
|
||||
Ready for Wave 5 (multi-agent QA) and Wave 6 (release v1.4.22).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Wave 5 reconcile — v1.4.22
|
||||
|
||||
Branch: `develop` (10 reconcile commits ahead of `363e73c`).
|
||||
Date: 2026-05-10.
|
||||
Inputs: six W5 reviews + product-lead strategic memo.
|
||||
|
||||
## Summary
|
||||
|
||||
- 0 CRITICAL findings across all reviews.
|
||||
- 7 HIGH findings — all applied inline.
|
||||
- ~6 MEDs applied inline (judgment-pick high-leverage).
|
||||
- Remaining MED + LOW deferred to `.planning/v1422-backlog.md`.
|
||||
|
||||
## Commits
|
||||
|
||||
| # | SHA | Title | Cross-ref |
|
||||
|---|-----|-------|-----------|
|
||||
| 1 | `75ccc01` | fix(coach): graceful fallback when sentinel-only output strips empty | Code-H1 |
|
||||
| 2 | `ef597bf` | fix(insights): align BD-Zielbereich delta math with comparison window | Code-H2 |
|
||||
| 3 | `b1ce6eb` | refactor(auth): fold onboarding-cookie write into createSession + destroySession | Sr-H1 |
|
||||
| 4 | `2075d39` | refactor(api): extract createSseStream helper from chat route | Sr-H2 |
|
||||
| 5 | `a2e24b1` | a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce) | Design-H1, Design-H3, Code-MED-4, Design-LOW-4 |
|
||||
| 6 | `651a529` | fix(insights): collapse BP tile to single secondary row at <sm | Design-H2 |
|
||||
| 7 | `65a8b5a` | fix(coach): pin medical disclaimer at bottom of message thread | Design-H3, Design-M4 |
|
||||
| 8 | `1631d80` | refactor: simplify-pass apply-yes cleanup | S-01..S-05 |
|
||||
| 9 | `6c0c69c` | fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race | Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3 (×2), Code-LOW-5 |
|
||||
| 10 | _this commit_ | docs(planning): record Wave 5 reconcile + v1.4.22 backlog | meta |
|
||||
|
||||
## Findings by bucket
|
||||
|
||||
### HIGH (7 / 7 applied)
|
||||
|
||||
- **Code-H1** ✅ sentinel-only output no longer leaks `---KEYVALUES---` markers. New integration test covers the empty-prose-after-strip branch.
|
||||
- **Code-H2** ✅ BD-Zielbereich tile compareDelta now matches caption: subtracts `bpInTargetPctPriorMonth` / `bpInTargetPctPriorYear` instead of `bpInTargetPctAllTime`. Two new bp-in-target unit tests + two insights-polish guards.
|
||||
- **Sr-H1** ✅ `createSession` requires `onboardingPending` parameter; cookie-write fan-out reduced to one site. Three new auth-flow integration tests pin the contract.
|
||||
- **Sr-H2** ✅ `createSseStream` extracted to `src/lib/sse/create-stream.ts`. Three unit tests pin sync, async, and throw paths.
|
||||
- **Design-H1** ✅ section nav `aria-current`, `focus-visible` ring, `motion-reduce` gate, `bg-background/95 backdrop-blur`, `-mx-` removed, scrollbar hidden, observer ratio-sorted, `scroll-mt-28` → `scroll-mt-16`.
|
||||
- **Design-H2** ✅ BP tile `<sm` collapses to a single combined "All-time X% · Δ Y last month" secondary row; `>=sm` keeps the full layout.
|
||||
- **Design-H3** ✅ medical disclaimer pinned at bottom of message thread (always visible regardless of viewport); rail footer kept for desktop redundancy.
|
||||
|
||||
### MED (applied)
|
||||
|
||||
- **Sec-MED-1** ✅ `hl_onboarding` cookie `SameSite=Strict`.
|
||||
- **Sec-MED-2** ✅ `/onboarding` exact-match + subroute guard.
|
||||
- **Code-MED-2** ✅ dead i18n keys (`coach.settings`, `coach.settingsTooltip`) dropped.
|
||||
- **Code-MED-3 (Berlin tz)** ✅ `berlinDayKey()` lifted to `src/lib/analytics/berlin-day.ts`; targets sparkline + analytics route share the helper. Four new unit tests for DST + UTC-midnight edge cases.
|
||||
- **Code-MED-3 (streaming race)** ✅ 150 ms grace window on message-thread suppresses the persisted twin while the streaming bubble is still in flight.
|
||||
- **Code-MED-4** ✅ IntersectionObserver picks highest-intersection entry; folded into Design-H1 commit.
|
||||
- **Code-LOW-5** ✅ `queryKeys.dashboardWidgets()` centralised; three call sites migrated.
|
||||
|
||||
### MED (deferred)
|
||||
|
||||
All other MEDs documented in `.planning/v1422-backlog.md` under "Wave 5 reconcile carry-over":
|
||||
|
||||
- Sr-M1..M5 (analytics-route unbounded findMany; targets-route 7-pass sparkline; targets-page render mutation; provenanceFromJson hand-rolled validation; sentinel parser malformed enum).
|
||||
- Design-M1..M3, M5..M7 (coach evidence aria semantics; sparkline min-points threshold; severity badge eyebrow + i18n; row-fill heuristic; live-preview affordance; sparkline aria sentiment).
|
||||
- Code-MED-1 already applied; Code-MED-2 already applied; Code-MED-3 already applied (×2); Code-MED-4 already applied.
|
||||
|
||||
### LOW (deferred)
|
||||
|
||||
All LOWs deferred to backlog: Code-LOW-1..LOW-4, Sec-LOW-1..LOW-4, Design-L1..L5, Sr-L1..L4.
|
||||
|
||||
### Simplify (5 / 5 apply-yes applied)
|
||||
|
||||
- **S-01..S-05** ✅ all five applied in single `refactor: simplify-pass apply-yes cleanup` commit (`1631d80`).
|
||||
- **S-M1..S-M4** deferred (maybe-tier).
|
||||
- **S-N1..S-N6** rejected (per simplify review).
|
||||
|
||||
## Test count delta
|
||||
|
||||
- Baseline (pre-W5 reconcile): 2097 unit tests across 243 files.
|
||||
- Final (post-W5 reconcile): **2111 unit tests across 245 files** (+14 unit tests, +2 files).
|
||||
- Integration tests: 81 → 84 (+3) per the new auth-flow + coach-chat + proxy entries; verified locally (`pnpm test:integration auth-flow` passes 6/6).
|
||||
|
||||
New test surfaces:
|
||||
- `src/lib/sse/__tests__/create-stream.test.ts` (3 tests)
|
||||
- `src/lib/analytics/__tests__/berlin-day.test.ts` (4 tests)
|
||||
- `src/lib/analytics/__tests__/bp-in-target.test.ts` (3 new tests for priorMonth/priorYear/null windows)
|
||||
- `src/app/__tests__/insights-polish.test.ts` (2 new guards for Code-H2)
|
||||
- `src/__tests__/proxy-onboarding-redirect.test.ts` (2 new guards for Sec-MED-2)
|
||||
- `tests/integration/coach-chat.test.ts` (1 new test for sentinel-only leak)
|
||||
- `tests/integration/auth-flow.test.ts` (3 new tests for createSession/destroySession cookie contract)
|
||||
|
||||
## Verification gates run between commits
|
||||
|
||||
For every commit:
|
||||
- `pnpm typecheck` (clean — 0 errors)
|
||||
- `pnpm test --run` (passes after each change; final 2111/2111)
|
||||
- `pnpm lint` (15 pre-existing warnings, 0 errors)
|
||||
|
||||
For HIGH 1 + HIGH 3 + Sec-MED-2 (per brief):
|
||||
- `pnpm test:integration auth-flow` 6/6 passes
|
||||
- `pnpm test --run src/__tests__/proxy-onboarding-redirect.test.ts` 9/9 passes
|
||||
|
||||
## Items the maintainer should weigh in on
|
||||
|
||||
1. **CSP `connect-src` for `https://chatgpt.com`** is gated to `/settings/ai/**` only (per existing proxy.ts comment). The Coach SSE route hits OpenAI/Anthropic via the provider runner, which runs server-side — no browser fetch — so this is correct. Calling out only because the W5 simplify review flagged proxy.ts as out-of-scope and the cookie attribute changed; verify the connect-src isn't accidentally re-broadened by a future MED-2 follow-up.
|
||||
|
||||
2. **Insights page split deferred to v1.5 P5** per the product-lead memo. The W5 senior-dev review's L3 (`InsightsSectionNav` extraction) is a natural sub-task at that point. Leaving the page at 1700+ LOC for one more cycle is the right call but worth a v1.4.23 reminder when the next polish PR opens.
|
||||
|
||||
3. **Sr-M5 sentinel parser malformed-enum** carries the highest signal-to-effort ratio of the deferred MEDs (4-state enum, ~30 LOC, tightens ops observability before iOS volume lands). Suggest pulling forward to v1.4.23 if room.
|
||||
|
||||
4. **The `/api/auth/me` resync write** stays as a fall-back per the original v1.4.22 C4 design. Sec-LOW-1 calls it out only as a "future cargo-cult" risk — not an actual bug. Optional one-line code comment about not growing the GET-side mutation. Filed in backlog as LOW.
|
||||
|
||||
## Anything I couldn't reconcile
|
||||
|
||||
- All 7 HIGH applied successfully. No unresolved blockers.
|
||||
- The Code-LOW-2 colon-bearing label parser test was deferred (LOW + low-priority). The defensive contract is documented in the prompt few-shots; an explicit constraint comment in the prompt header is the cleanest fix when v1.4.23 next-touches the prompt.
|
||||
@@ -3,6 +3,52 @@
|
||||
Carry-over from v1.4.20 marathon foundation phases. Pick from this list
|
||||
when v1.4.21 starts.
|
||||
|
||||
## Closed in v1.4.22
|
||||
|
||||
The Wave-4 cleanup pass closed the following items. See
|
||||
`.planning/phase-W4-report.md` for full SHAs + reasoning.
|
||||
|
||||
- **C1** Zielwerte sparkline + Δ-vs-last-month — landed in commit 1
|
||||
- **C2** api-tokens 5th-attempt scrollbar fix — landed in commit 2
|
||||
- **C3** Coolify `?force=true` + howto for the UI checkbox — landed in
|
||||
commit 3
|
||||
- **C4** AuthShell post-hydration redirect → moved to proxy.ts — landed
|
||||
in commit 4
|
||||
- **C5** node-26-alpine — PR #162 closed (no Dockerfile bump; Next.js
|
||||
16 still pins node 22 LTS and node:26-alpine ships without corepack)
|
||||
- **F-32** triple help blocks on /settings/dashboard — landed in
|
||||
commit 5
|
||||
- **F-33** mood subtitle ampersand → "und" / "and" — landed in commit 5
|
||||
- **F-34** measurements subtitle ampersand → "und" / "and" — landed in
|
||||
commit 5
|
||||
- **F-35** Send-test button label inconsistency — unified on the
|
||||
shorter "Send test" / "Test senden" — landed in commit 5
|
||||
- **F-44** `LOKI_ENDPOINT` env-var leakage in user-facing copy — landed
|
||||
in commit 5
|
||||
- **F-45** admin/users column header "Users" → "Name" (new
|
||||
`admin.userName` key) — landed in commit 5
|
||||
- **F-47** /notifications event-column header collision with the page
|
||||
title — new `notifications.eventColumn` key — landed in commit 5
|
||||
- **F-50** DE error-toast tone — "Bitte erneut versuchen" → "Bitte
|
||||
versuche es erneut" across six keys — landed in commit 5
|
||||
- **F-58** admin/reminders icon swap (Clock → Bell to match the
|
||||
admin-shell nav) — landed in commit 5
|
||||
- **F-62** DE dashboard `greetingSalutation` ("Hi" → "Hallo, {name}")
|
||||
— landed in commit 5
|
||||
- **D-CR-M-03** formatTokenName regex broadened to non-Z ISO offsets
|
||||
— landed in commit 5
|
||||
- **D-DSGN-M-04** moodLog "Copy" button uses `common.copy` instead of
|
||||
stripping `!` off `common.copied` — landed in commit 5
|
||||
- **FX 1** 191 `Marc` source-comment references swept (test fixtures
|
||||
kept) — landed in commit 6
|
||||
- **FX 2** DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
|
||||
normalised to English-only — landed in commit 6
|
||||
- **FX 3** `CLAUDE.md` renamed to `CONTRIBUTING-AI.md`; `AGENTS.md`
|
||||
retained for multi-agent compatibility — landed in commit 6
|
||||
|
||||
The remaining MED + LOW items below either deferred to v1.4.23 (most
|
||||
of the heavy reviewer findings) or stay open as a hygiene PR.
|
||||
|
||||
## Phase D — v1.4.20 reconcile carry-over
|
||||
|
||||
Items raised by the six-reviewer Phase-D pass that were either too
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# v1.4.22 backlog
|
||||
|
||||
Items deferred from the W5 reconcile pass. Carried into v1.4.23 +.
|
||||
|
||||
## Wave 5 reconcile carry-over
|
||||
|
||||
### Code review
|
||||
|
||||
- **Code MED-1** (`src/app/api/insights/targets/route.ts:199-216`): `sparklinePoints()` once-per-day bucket now uses Berlin tz (lifted to `src/lib/analytics/berlin-day.ts` in W5 reconcile). DST-boundary edge-case unit test added. `analytics/route.ts` now imports the shared helper. — APPLIED in W5 reconcile, kept here for trace.
|
||||
- **Code MED-4** (`src/app/insights/page.tsx:1729-1747`): IntersectionObserver picks the highest-intersection entry now. — APPLIED in W5 reconcile (folded into Design-H1 commit), kept here for trace.
|
||||
- **Code LOW-1** (`src/components/insights/trends-row.tsx:148`): `<MoodChart title={…} />` self-resolves a localised title; the explicit prop tightens coupling. Drop the `title` prop on all three TrendsRow callsites OR add a comment. Cosmetic only.
|
||||
- **Code LOW-2** (`src/lib/ai/coach/keyvalues.ts:53`): `parseKeyValueLine` regex uses the first `:` as label/value boundary. A future prompt rev that adds a time-of-day pin would break (`"Mon 6 May, 7:30 reading: 142/88"`). Add a parser test for a colon-bearing label as a regression pin OR document the constraint in the prompt explicitly.
|
||||
- **Code LOW-3** (`src/app/api/insights/chat/route.ts:284-291`): sentinel parse failure logged with `kept` count but not the prompt-version-vs-malformed-cause breakdown. Extend `SentinelParseResult` with `reason: "ok" | "no_close_marker" | "truncated" | "empty_block"`.
|
||||
- **Code LOW-4** (`src/app/targets/page.tsx:272-273`): `Math.min(...points)` / `Math.max(...points)` spread-call patterns can blow stack at ~10k arguments. Replace with `for` loop or `points.reduce(...)`.
|
||||
- **Code LOW-5** (`src/lib/query-keys.ts`): `["user", "dashboardWidgets"]` centralised in `queryKeys.dashboardWidgets()` in W5 reconcile, three call sites migrated. — APPLIED.
|
||||
|
||||
### Security review
|
||||
|
||||
- **Sec MED-1** (`src/lib/auth/session.ts:32`): `hl_onboarding` cookie `SameSite=Lax` → `Strict`. — APPLIED in W5 reconcile.
|
||||
- **Sec MED-2** (`src/proxy.ts:28-33` + `:152`): `/onboarding` `startsWith` match tightened to exact + subroute. — APPLIED in W5 reconcile.
|
||||
- **Sec LOW-1** (`/api/auth/me`): GET that mutates state (cookie write) on every call. Idempotent; review left as-is, optional comment about not growing.
|
||||
- **Sec LOW-2** (Coach few-shots): synthetic numeric values baked into the system prompt. Optional `WARNING` comment so a future contributor doesn't paste real readings.
|
||||
- **Sec LOW-3** (`src/app/api/insights/chat/route.ts:211-220`): transcript builder flattens role labels into a single user prompt; user-self-injection only. v1.4.23 candidate: switch to structured chat-completion payload.
|
||||
- **Sec LOW-4** (`src/lib/ai/coach/keyvalues.ts:147-180`): truncated/closeMissing booleans not surfaced to wide-event meta. Extend with `truncated` / `closeMissing` for ops visibility.
|
||||
|
||||
### Design review
|
||||
|
||||
- **Design M1** (`coach-panel/message-thread.tsx:259-307`): coach evidence disclosure has no `aria-controls` / `aria-expanded` semantics; `<summary>` reports without expanded state in iOS Safari ≤ 17.3. Wrap the summary in a 36-px-min hit zone, add `aria-expanded` mirror via `useState` + `onToggle`.
|
||||
- **Design M2** (`src/app/targets/page.tsx:264-303`): Sparkline has no min-points threshold guard — 3-4 points spread over 30 days reads "lots of activity". Either require `length >= 7` before painting, or render at `stroke-opacity:0.5` for `length < 10` plus a `{n} of 30 days logged` caption.
|
||||
- **Design M3** (`src/components/insights/recommendation-card.tsx:328-335`): severity badge uppercase eyebrow re-introduces the v1.4.20 M4 marketing pattern AND surfaces the internal severity enum (`urgent` / `suggestion`) — no i18n. Drop `tracking-wide uppercase`, replace `{norm.severity}` with `{t(\`insights.recommendation.severity.\${norm.severity}\`)}`. Add EN/DE keys.
|
||||
- **Design M4** (Coach disclaimer reach): pinned the disclaimer at the bottom of the message thread in W5 reconcile (Design-H3 fix). — APPLIED.
|
||||
- **Design M5** (`src/app/insights/page.tsx:1070-1078`): row-fill rule on the medication-correlation row uses `showMoodSection` heuristic. Either hide the BP-medication card entirely when `bpMedicationScatterData.length < 5` (let mood-vs-BP take 100 %) or restore the fixed 50/50 with per-card empty state.
|
||||
- **Design M6** (`dashboard-layout-section.tsx:204-241`): comparison-toggle has no live-preview affordance. Add a tiny `<TrendCard>` preview swatch under the picker.
|
||||
- **Design M7** (`src/app/targets/page.tsx:286-301`): SVG sparkline `aria-hidden` skips the only "things changing" signal for SR users. Add `aria-label` to the `<TrendIcon>` parent OR move the sentiment into the delta caption prose.
|
||||
- **Design L1** (i18n voice asymmetry on `coach.evidenceLabel`): EN ("What I'm looking at") declarative; DE ("Worauf bezieht sich das?") curious. Decide on a unified voice.
|
||||
- **Design L2** (`coach-panel/message-thread.tsx:286-304`): React `key={\`\${kv.label}-\${idx}\`}` allows duplicate-label collisions. Use a hash of `\${label}-\${window}-\${value}`.
|
||||
- **Design L3** (`targets/page.tsx:288`): sparkline `preserveAspectRatio="none"` distorts y vs x. Either keep + accept, or fix Y-range to the target range.
|
||||
- **Design L4** (`scroll-mt-28` overshoot): tuned to `scroll-mt-16` in W5 reconcile (Design-H1 commit). — APPLIED.
|
||||
- **Design L5** (api-tokens table date wrap): cells wrap to two lines on 1024 px. Either short-format `formatDate` + tooltip, or push `<col>` width to 14 %.
|
||||
|
||||
### Senior-dev review
|
||||
|
||||
- **Sr H1** (createSession cookie fan-out): folded into createSession in W5 reconcile. — APPLIED.
|
||||
- **Sr H2** (chat route SSE construction duplicated 3×): extracted `createSseStream` helper in W5 reconcile. — APPLIED.
|
||||
- **Sr M1** (analytics route fetches ALL BP measurements ever, twice): bound by horizon (5 years) OR materialise a per-user `bp_target_aggregate` row refreshed on measurement-write.
|
||||
- **Sr M2** (targets route's 7-pass sparkline computation): collapse to a single Postgres-side `GROUP BY date_trunc('day', measured_at), type`.
|
||||
- **Sr M3** (targets page mutates server response inside render): wrap the glucose-target conversion in `useMemo([data, t, displayGlucoseUnit])` to keep refs stable.
|
||||
- **Sr M4** (`provenanceFromJson` hand-rolls keyValues validation): replace 35 lines of manual `typeof` checks with `coachKeyValueSchema.array().safeParse(parsed.keyValues)`. Same `safeParse` lift fixes the windows / metrics enum-membership gap.
|
||||
- **Sr M5** (sentinel parser conflates 3 distinct malformed conditions): four-state enum (`"ok" | "truncated" | "unclosed" | "empty"`) so ops dashboards can distinguish the cases.
|
||||
- **Sr L1** (`Sparkline` `preserveAspectRatio="none"`): same as Design L3.
|
||||
- **Sr L2** (`setOnboardingPendingCookie` `async` but does no async work): could be sync if `cookies()` resolved by caller. v1.5 cleanup.
|
||||
- **Sr L3** (`InsightsSectionNav` re-creates IntersectionObserver on every mount): single mount per page, so the practical cost is negligible — observer should move with the component on extraction.
|
||||
- **Sr L4** (`as TargetItem` cast in `targets/route.ts:281`): the BP entry ships sys-only data without typing it. Pre-existing.
|
||||
|
||||
### Simplify review
|
||||
|
||||
- **S-01..S-05**: applied in W5 reconcile (single commit). — APPLIED.
|
||||
- **S-M1..S-M5**: maybe-tier — leave for now; revisit when adjacent work re-touches the surfaces.
|
||||
|
||||
### Product Lead memo (cross-link)
|
||||
|
||||
- **Insights page split** held to v1.5 P5 (Apple Health work edits the same sub-trees).
|
||||
- **Pearson p-value normal approximation at low df**: 30-LOC incomplete-beta or raise gate to df >= 20. Pull in v1.5 P0 before auto-discovery ships.
|
||||
- **Coach token-budget UX**: 25 000 tokens/user/day = ~13 turns. iOS surface in v1.5 will multiply this. Replace generic 429 with localised "limit reached, resets at 00:00 UTC".
|
||||
- **Conversation persistence eviction policy**: auto-archive after 90 days, hard-delete after 365. v1.5 P3 latest.
|
||||
- **OpenAPI spec drift CI guard**: pull forward to v1.4.23 if possible.
|
||||
- **CoachDrawer `key={prefill}` weaponising React keys (Sr-HIGH-4)**: heavy refactor; v1.5 P3 latest.
|
||||
- **Cross-user feedback aggregation cron** (P6 in product-lead memo): wired alongside v1.5 P3 once usage volume materialises.
|
||||
+385
-433
@@ -1,5 +1,202 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.22] — 2026-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-target sparkline + Δ-vs-last-month caption on the Targets /
|
||||
Zielwerte page.** Each `<TargetCard>` grew a 30-day inline SVG
|
||||
sparkline beneath the range bar plus a localised "Δ −2.3 kg vs.
|
||||
last month" caption. The API ships `points30d` and
|
||||
`deltaVsLastMonth` per target; both null when either window has
|
||||
fewer than 3 readings so cold-start accounts don't paint a
|
||||
misleading flat trace. BMI piggybacks on the weight series so its
|
||||
sparkline shares the range bar's y-axis.
|
||||
- **Sticky section navigation above the Insights hero.** A pinned
|
||||
strip (Allgemein / Blutdruck / Gewicht / Puls / Stimmung in DE;
|
||||
General / Blood Pressure / Weight / Pulse / Mood in EN) lifts the
|
||||
section tabs above the hero so they stay visible during scroll.
|
||||
Active section tracks the highest-intersection observed entry;
|
||||
`aria-current`, focus-visible ring, and `motion-reduce` gating
|
||||
ship from day one.
|
||||
- **Comparison-overlay as a single global preference under Settings
|
||||
→ Dashboard.** The on-surface `<CompareToggle />` retired from
|
||||
`/insights`; the canonical picker has lived in Settings →
|
||||
Dashboard since v1.4.16 phase B8 and every chart already consumed
|
||||
it. Two surfaces for the same concept violated the
|
||||
no-split-Settings rule.
|
||||
- **Collapsible evidence disclosure under each Coach assistant
|
||||
message.** Numbers move out of the prose into a
|
||||
`---KEYVALUES---` … `---END---` sentinel block that the route
|
||||
parses out server-side and renders as a "Worauf bezieht sich
|
||||
das?" / "What I'm looking at" `<details>` disclosure under each
|
||||
assistant turn. Closed by default, hidden when no key-values came
|
||||
back. Hard caps on the sentinel (1 KB payload, 8 lines max,
|
||||
per-line Zod) so a prompt-injection attempt can't grow the
|
||||
persisted envelope.
|
||||
- **User avatar parity with the Coach avatar.** The Coach drawer's
|
||||
user-side bubble used a smaller initials avatar than the Coach's
|
||||
gradient one. The user avatar now reuses the existing
|
||||
`gravatarUrl` field from `/api/auth/me` at the same dimensions
|
||||
as the Coach avatar; initials fall back when no Gravatar is
|
||||
configured.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Coach prompt rewrite — warm, motivational-interviewing tone
|
||||
with prose-first responses + evidence collapsible.** PROMPT_VERSION
|
||||
ratchets 4.20.2 → 4.22.0 (first minor-digit bump in v1.4.x). The
|
||||
Coach used to open every reply with a number — clinical,
|
||||
database-cursor energy. The new persona is "warm, neugierig,
|
||||
zurückhaltend": a partner sitting alongside the user, not pushing
|
||||
data. Numbers move into the collapsible evidence block; the prose
|
||||
reads like a real conversation. Persona + sentinel land together
|
||||
because either alone is incomplete.
|
||||
- **BP-in-target headline re-anchored to the last-30-day window.**
|
||||
Fourth attempt at this metric. v1.4.19 routed the headline to
|
||||
`allTime` to fix the algorithmic 50/50/50 pin; v1.4.22 re-anchors
|
||||
to `windows.last30Days?.pct` and surfaces all-time as a sub-row,
|
||||
because v1.4.19's fix was emotionally wrong — the headline became
|
||||
the slowest-moving aggregate possible, punishing a user who put
|
||||
in real recent work. The tile also gets a synthesised trend arrow
|
||||
(slope from 7d/30d delta), a 7-day-trend chip, and a
|
||||
comparison-overlay caption.
|
||||
- **"Muster" renamed to "Zusammenhänge" (DE) / "Patterns" renamed
|
||||
to "Relationships" (EN).** The picked-bucket-of-correlations row
|
||||
read like an autopsy. The new label sits closer to what the row
|
||||
actually shows. Picked "Zusammenhänge" over "Trends" because the
|
||||
row directly above already uses Trends.
|
||||
- **Onboarding redirect for users with `null onboardingCompletedAt`
|
||||
moves to server-side enforcement in `proxy.ts`.** The previous
|
||||
post-hydration redirect inside `<AuthShell>` `useEffect` produced
|
||||
a brief dashboard flash on incomplete onboarding. The proxy runs
|
||||
in the Edge runtime so the auth routes mirror
|
||||
`onboardingCompletedAt` into a non-httpOnly `hl_onboarding`
|
||||
cookie; the proxy short-circuits before hydration. Tampering only
|
||||
skips a UX hint and never bypasses a server check.
|
||||
- **`setOnboardingPendingCookie` folded into `createSession()`.**
|
||||
Issuing a session without onboarding state used to require two
|
||||
call sites to stay in sync; the helper now takes
|
||||
`onboardingPending` as a required parameter so the contract is
|
||||
type-impossible to break.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Raw `metric:<TYPE>` token leaks in recommendation prose.**
|
||||
`<RecommendationCard>` text is now wrapped in
|
||||
`stripChartTokens()`. The leak traced to a single missing call
|
||||
on the recommendation path; chart tokens never belonged in user
|
||||
copy.
|
||||
- **DE locale `componentMood`, `componentBp`, `componentCompliance`
|
||||
rendered English nouns in the German bundle.** Four Health-Score
|
||||
component labels normalised to `Stimmung`, `Blutdruck`,
|
||||
`Einnahmetreue`, `Gewicht`. The i18n-integrity test pins the
|
||||
contract.
|
||||
- **Admin / API tokens horizontal scrollbar at desktop + iPad-mini
|
||||
viewports (5th attempt).** A live Playwright probe confirmed
|
||||
`whitespace-nowrap` on the date `<td>`s was the residual
|
||||
culprit. The two classes are gone; date + time wraps to two
|
||||
lines on narrow viewports. The earlier four fixes had targeted
|
||||
the wrong layout layer.
|
||||
- **Sentinel-only / malformed `---KEYVALUES---` block.** When the
|
||||
model emits a sentinel-only or malformed envelope, the fallback
|
||||
now produces a polite invitation ("I'd like to look at this with
|
||||
you — could you share which window you want me to focus on?")
|
||||
instead of surfacing the raw marker. A new integration test
|
||||
covers the empty-prose-after-strip branch.
|
||||
- **BD-Zielbereich tile delta math period-aligned with the
|
||||
comparison window.** The tile's compareDelta was subtracting
|
||||
`bpInTargetPctAllTime` while the caption said "vs last month",
|
||||
which produced numbers the caption couldn't justify. It now
|
||||
subtracts `bpInTargetPctPriorMonth` / `bpInTargetPctPriorYear`
|
||||
to match. Two new bp-in-target unit tests + two
|
||||
insights-polish guards.
|
||||
- **Coach drawer settings cog removed.** The cog was a dead button
|
||||
in v1.4.21; per-user prompt-tuning is deferred to v1.4.23. No
|
||||
dead buttons in this release.
|
||||
- **Coach disclaimer pinned at the bottom of the message thread.**
|
||||
Clinical-adjacent UI must not gate the disclaimer behind a
|
||||
chevron tray; the disclaimer now stays visible at the bottom of
|
||||
the message thread on every viewport, and the rail-footer
|
||||
duplicate kept for desktop redundancy.
|
||||
- **`hl_onboarding` UX-hint cookie now `SameSite=Strict`.** A
|
||||
cross-site request couldn't usefully exfiltrate the cookie
|
||||
(no auth value, only an onboarding state flag) but `Lax` was
|
||||
still over-permissive. Strict aligns with the cookie's
|
||||
same-site-only consumer.
|
||||
- **`PUBLIC_PATHS` exact-match guard against future subroute
|
||||
prefix bypasses.** `/onboarding` is now exact-match +
|
||||
explicit-subroute, not a `startsWith` check. Two new proxy
|
||||
guards pin the contract.
|
||||
- **`targets/route.ts` daily buckets keyed in Europe/Berlin.**
|
||||
The targets sparkline was the last analytics surface still
|
||||
bucketing in UTC, which produced a one-day-off trace for users
|
||||
in CEST. `berlinDayKey()` lifted to a shared
|
||||
`src/lib/analytics/berlin-day.ts` helper; four new DST + UTC-
|
||||
midnight edge-case unit tests.
|
||||
- **Streaming bubble vs persisted-twin race window.** The
|
||||
150ms grace window suppresses the persisted twin while the
|
||||
in-flight streaming bubble is still rendering, so the thread
|
||||
never paints two copies of the same reply for a frame.
|
||||
- **Sticky section navigation a11y polish.** `aria-current` on
|
||||
the active section; focus-visible ring on keyboard navigation;
|
||||
`motion-reduce` honoured for smooth-scroll; the glow-bleed
|
||||
fixed via `bg-background/95 backdrop-blur`; the mobile cliff
|
||||
tightened from `scroll-mt-28` to `scroll-mt-16` so the strip
|
||||
no longer eats the heading at 280px viewports.
|
||||
- **BP tile mobile density at <sm.** All-time + delta now
|
||||
collapse into one secondary line on small viewports; the
|
||||
full layout returns at `>=sm`.
|
||||
|
||||
### Refactor
|
||||
|
||||
- **`createSseStream` shared helper extracted from the chat
|
||||
route.** Preparation for v1.5 iOS streaming endpoints, which
|
||||
will reuse the SSE primitives. Three unit tests pin sync,
|
||||
async, and throw paths. Source: `src/lib/sse/create-stream.ts`.
|
||||
- **`<TokenStatusBadge>` extracted from desktop + mobile
|
||||
api-token surfaces.** The badge logic was duplicated verbatim
|
||||
in two layouts; one component, two consumers.
|
||||
- **Five simplify apply-yes items in one commit.** `canSubmit`
|
||||
collapse, weekly-report `<Button>` dedup, and three smaller
|
||||
cleanups identified in the W5 simplify pass.
|
||||
|
||||
### Operational / hygiene
|
||||
|
||||
- **Coolify image-digest auto-deploy.** Instructions for the
|
||||
one-time UI-toggle ("Watch image registry for new digests")
|
||||
live at `.planning/coolify-auto-deploy-howto.md`. Future
|
||||
releases should drop the host-side retag fallback the moment
|
||||
the toggle is on.
|
||||
- **191 maintainer-name references in `src/` source comments
|
||||
swept** (FX carry-over from v1.4.20). Test fixtures kept as
|
||||
opaque test data.
|
||||
- **DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
|
||||
normalised to English-only.** Per the English-only voice
|
||||
rule.
|
||||
- **`CLAUDE.md` filename retired (FX carry-over)** so the
|
||||
filename is no longer AI-vendor-specific; `CONTRIBUTING.md`
|
||||
reference updated. `AGENTS.md` stays for multi-agent
|
||||
compatibility.
|
||||
|
||||
### Deferred to v1.4.23
|
||||
|
||||
See `.planning/v1422-backlog.md` for the full carry-over list.
|
||||
Highlights: sentinel parser malformed-enum hardening (Sr-M5);
|
||||
analytics-route unbounded `findMany` paging; targets-route
|
||||
7-pass sparkline coalesce; `CoachDrawer key={prefill}`
|
||||
controlled-prop refactor (Sr-HIGH-4); per-user prompt-tuning
|
||||
surface; medication_schedules.days_of_week schema-drift cleanup.
|
||||
|
||||
### Deferred to v1.5 (iOS push)
|
||||
|
||||
See `.planning/phase-W5-v1422-product-lead-review.md` for the
|
||||
full v1.5 plan. Headline: iOS native client + Apple Health
|
||||
ingest contract (HRV, Sleep, Resting HR, Steps, BodyFat,
|
||||
Glucose); per-metric APNs alerts; OpenAPI spec drift CI gate;
|
||||
Coach extension for the new measurement types
|
||||
(PROMPT_VERSION 4.22.0 → 5.0.0).
|
||||
|
||||
## [1.4.21] — 2026-05-10
|
||||
|
||||
### Fixed
|
||||
@@ -549,395 +746,189 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Backups: vollständig.** Die Sicherung am Sonntag früh ist jetzt
|
||||
nur noch der Anfang. `/admin/backups` lässt jeden Snapshot als
|
||||
`.json` herunterladen, neue Snapshots hochladen und — hinter einer
|
||||
dreifachen Bestätigung („RESTORE“ tippen + Dialog + Confirm) —
|
||||
einen Snapshot direkt in die Datenbank zurückspielen. Jede
|
||||
Operation (Run, Download, Upload, Restore inkl. Start/Failure)
|
||||
landet im Audit-Log mit Akteur und Ziel-Snapshot.
|
||||
_Backups become a full lifecycle. The Sunday-morning snapshot is
|
||||
no longer the end of the road. `/admin/backups` lets you download
|
||||
any snapshot as `.json`, upload a new one, and — behind a
|
||||
triple-confirm gate (type “RESTORE” + dialog + confirm) — restore
|
||||
a snapshot straight into the database. Every operation (run,
|
||||
download, upload, restore including start/failure) is recorded in
|
||||
the audit log with actor and target snapshot._
|
||||
- **Erfolge-Seite (`/achievements`) und Dashboard-Karte.** Dedizierte
|
||||
Seite zeigt freigeschaltete und gesperrte Erfolge inklusive
|
||||
Fortschrittsbalken (`{aktuell} / {ziel}`), gruppiert nach
|
||||
Kategorie (Medikation, Vitalwerte, Sicherheit, Engagement). Auf
|
||||
dem Dashboard sitzt eine zuschaltbare „Letzte Erfolge“-Karte; die
|
||||
Sichtbarkeit lässt sich in den Layout-Einstellungen umschalten.
|
||||
_New `/achievements` page lists locked + unlocked achievements
|
||||
with progress bars (`{current} / {target}`), grouped by category
|
||||
(medication, vitals, security, engagement). The dashboard gains a
|
||||
toggleable “Recent achievements” card; visibility lives in the
|
||||
Layout settings._
|
||||
- **Onboarding-Tour für Erstnutzer.** Beim ersten Dashboard-Besuch
|
||||
läuft ein Spotlight-Walk-Through über die Tile-Strip, das
|
||||
Quick-Add-Menü, Insights, Integrationen und Erfolge. Skippable mit
|
||||
Esc, Tastatur-navigierbar, `prefers-reduced-motion` respektiert.
|
||||
Erneut anstoßen jederzeit unter Settings → Account → „Tour
|
||||
wiederholen“.
|
||||
_New onboarding tour for first-run users. A spotlight walk-through
|
||||
on first dashboard load points out the tile strip, quick-add menu,
|
||||
insights, integrations, and achievements. Skippable with Esc,
|
||||
fully keyboard-navigable, honors `prefers-reduced-motion`. Replay
|
||||
the tour any time from Settings → Account._
|
||||
- **Doctor-Report v2.** Vor dem PDF-Generieren öffnet sich ein
|
||||
Dialog mit konfigurierbarem Zeitraum (Voreinstellungen 90 Tage / 6
|
||||
Monate / 12 Monate, manuell editierbar, max. 2 Jahre) und einem
|
||||
optionalen Praxis-Namen, der auf der Titelseite des PDFs
|
||||
erscheint. Der Praxis-Name wird als Nutzervorgabe gespeichert.
|
||||
_Doctor-report v2. Before the PDF is generated a dialog asks for
|
||||
the date range (presets 90 days / 6 months / 12 months, manually
|
||||
editable, max 2 years) and an optional practice name that appears
|
||||
on the PDF cover page. The practice name is persisted as a user
|
||||
preference._
|
||||
- **Auto-Deploy nach GHCR-Push.** Sobald die Docker-Publish-Action
|
||||
ein neues `:latest`-Image hochgeladen hat, ruft sie den
|
||||
Coolify-Deploy-Webhook auf. Coolify pingt anschließend einen
|
||||
internen Endpoint mit dem Ergebnis; Erfolg/Fehler/Unbekannt
|
||||
landen im Audit-Log, persistente Failures triggern eine
|
||||
Telegram-Benachrichtigung an alle Admins. Kein manuelles
|
||||
Force-Pull mehr nötig.
|
||||
_Auto-deploy on GHCR push. Once the Docker publish action ships a
|
||||
new `:latest`, it calls the Coolify deploy webhook. Coolify in
|
||||
turn pings an internal endpoint with the result; success / failure
|
||||
/ unknown all land in the audit log; persistent failures send a
|
||||
Telegram alert to every admin. No more manual force-pull on the
|
||||
host._
|
||||
- **Empty-States überall.** Brandneue Accounts und leere Listen
|
||||
bekommen jetzt überall einen sinnvollen Empty-State (Icon +
|
||||
Beschreibung + Call-to-Action), wo vorher nur weiße Fläche stand.
|
||||
Betroffen: Admin-Tabellen (Users, Backups, Login-Overview,
|
||||
API-Tokens, Feedback, Audit-Preview), Messungs-, Stimmungs-,
|
||||
Medikamenten- und Erfolge-Listen, Insights-Top-Level + BMI-ohne-
|
||||
Größe, Dashboard für komplett leere Accounts.
|
||||
_Empty states everywhere. Brand-new accounts and empty lists now
|
||||
always show a sensible empty state (icon + description + CTA)
|
||||
where there used to be just white space. Coverage: admin tables
|
||||
(users, backups, login-overview, api-tokens, feedback,
|
||||
audit-preview), measurement / mood / medication / achievement
|
||||
lists, the insights top-level + BMI-without-height view, and the
|
||||
dashboard for fully empty accounts._
|
||||
- **Status-Karten für Notification-Channels.** Settings →
|
||||
Benachrichtigungen zeigt für jeden Kanal (Telegram, ntfy, Web
|
||||
Push) den aktuellen Status (verbunden / fehlerhaft / deaktiviert),
|
||||
letzten Erfolg, letzten Fehler, Counter aufeinanderfolgender
|
||||
Fehlversuche und ggf. Deaktivierungsgrund. Buttons: „Wieder
|
||||
aktivieren“ (nur wenn auto-deaktiviert) + „Test-Nachricht
|
||||
senden“.
|
||||
_Notification channel status UI. Settings → Notifications now
|
||||
shows per-channel (Telegram, ntfy, Web Push) the current state
|
||||
(connected / error / disabled), last success, last failure,
|
||||
consecutive-failure counter, and disable reason if any. Buttons
|
||||
for “Re-enable” (only when auto-disabled) and “Send test”._
|
||||
- **Status-Karten für Withings + moodLog.** Settings → Integrationen
|
||||
zeigt für beide Provider Verbindungsstatus, letzten Sync, letzten
|
||||
Fehler und einen Reauth-Hinweis, sobald ein Refresh-Token
|
||||
abgelehnt wurde. Auf persistente Fehler (≥ 3 in Folge) bekommen
|
||||
Admins eine Telegram-Benachrichtigung, sofern der Kanal aktiviert
|
||||
ist.
|
||||
_Withings + moodLog integration status UI. Settings → Integrations
|
||||
shows per-provider the connection state, last sync, last error,
|
||||
and a reauth hint as soon as a refresh-token was rejected. After
|
||||
persistent failures (≥ 3 in a row) admins receive a Telegram alert
|
||||
if the channel is enabled._
|
||||
- **Top-Tiles unabhängig wählbar.** In den Layout-Einstellungen lässt
|
||||
sich für jede Metrik die Sichtbarkeit der Trend-Kachel oben und
|
||||
des Charts unten getrennt steuern. Bestehende Layouts behalten ihr
|
||||
bisheriges Verhalten (eine Schaltfläche steuert beide), bis du
|
||||
explizit den neuen Schalter umlegst.
|
||||
_Top dashboard tiles selectable. The layout settings now expose a
|
||||
separate toggle for each metric’s upper-row tile and lower-row
|
||||
chart. Existing saved layouts keep their previous behaviour (one
|
||||
switch controls both surfaces) until you explicitly flip the new
|
||||
toggle._
|
||||
- **7-Tage-Trend statt 7-Tage-Schnitt.** Auf jeder Tile-Kachel zeigt
|
||||
ein farbiger Delta-Indikator `(±N.N)` neben dem Wert die
|
||||
metrik-bewusste Veränderung der letzten 7 Tage gegenüber den
|
||||
davor. Label umbenannt von „7-Tage-Schnitt“ auf „7-Tage-Trend“.
|
||||
_7-day-trend instead of 7-day-average. Each tile gains a coloured
|
||||
delta indicator `(±N.N)` next to the value, showing the
|
||||
metric-aware change of the last 7 days vs. the prior 7. Label
|
||||
renamed from “7d average” to “7d trend”._
|
||||
- Backups become a full lifecycle. The Sunday-morning snapshot is no
|
||||
longer the end of the road. `/admin/backups` lets you download any
|
||||
snapshot as `.json`, upload a new one, and — behind a triple-confirm
|
||||
gate (type “RESTORE” + dialog + confirm) — restore a snapshot straight
|
||||
into the database. Every operation (run, download, upload, restore
|
||||
including start/failure) is recorded in the audit log with actor and
|
||||
target snapshot.
|
||||
- New `/achievements` page lists locked + unlocked achievements with
|
||||
progress bars (`{current} / {target}`), grouped by category (medication,
|
||||
vitals, security, engagement). The dashboard gains a toggleable “Recent
|
||||
achievements” card; visibility lives in the Layout settings.
|
||||
- New onboarding tour for first-run users. A spotlight walk-through on
|
||||
first dashboard load points out the tile strip, quick-add menu,
|
||||
insights, integrations, and achievements. Skippable with Esc, fully
|
||||
keyboard-navigable, honors `prefers-reduced-motion`. Replay the tour any
|
||||
time from Settings → Account.
|
||||
- Doctor-report v2. Before the PDF is generated a dialog asks for the date
|
||||
range (presets 90 days / 6 months / 12 months, manually editable, max 2
|
||||
years) and an optional practice name that appears on the PDF cover page.
|
||||
The practice name is persisted as a user preference.
|
||||
- Auto-deploy on GHCR push. Once the Docker publish action ships a new
|
||||
`:latest`, it calls the Coolify deploy webhook. Coolify in turn pings an
|
||||
internal endpoint with the result; success / failure / unknown all land
|
||||
in the audit log; persistent failures send a Telegram alert to every
|
||||
admin. No more manual force-pull on the host.
|
||||
- Empty states everywhere. Brand-new accounts and empty lists now always
|
||||
show a sensible empty state (icon + description + CTA) where there used
|
||||
to be just white space. Coverage: admin tables (users, backups,
|
||||
login-overview, api-tokens, feedback, audit-preview), measurement / mood
|
||||
/ medication / achievement lists, the insights top-level +
|
||||
BMI-without-height view, and the dashboard for fully empty accounts.
|
||||
- Notification channel status UI. Settings → Notifications now shows
|
||||
per-channel (Telegram, ntfy, Web Push) the current state (connected /
|
||||
error / disabled), last success, last failure, consecutive-failure
|
||||
counter, and disable reason if any. Buttons for “Re-enable” (only when
|
||||
auto-disabled) and “Send test”.
|
||||
- Withings + moodLog integration status UI. Settings → Integrations shows
|
||||
per-provider the connection state, last sync, last error, and a reauth
|
||||
hint as soon as a refresh-token was rejected. After persistent failures
|
||||
(≥ 3 in a row) admins receive a Telegram alert if the channel is
|
||||
enabled.
|
||||
- Top dashboard tiles selectable. The layout settings now expose a
|
||||
separate toggle for each metric’s upper-row tile and lower-row chart.
|
||||
Existing saved layouts keep their previous behaviour (one switch
|
||||
controls both surfaces) until you explicitly flip the new toggle.
|
||||
- 7-day-trend instead of 7-day-average. Each tile gains a coloured delta
|
||||
indicator `(±N.N)` next to the value, showing the metric-aware change of
|
||||
the last 7 days vs. the prior 7. Label renamed from “7d average” to “7d
|
||||
trend”.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/admin`-Übersicht neu gestaltet.** Statt einer Section-Grid
|
||||
zeigt die Übersicht jetzt eine Audit-Log-Vorschau (letzte
|
||||
Einträge, Akteur, Zielressource) und einen
|
||||
System-Status-Snapshot. Die Section-Grid wandert in die Sidebar.
|
||||
_The `/admin` overview replaces the section grid with an
|
||||
audit-log preview (recent entries, actor, target resource) and a
|
||||
system-status snapshot. The section grid moved into the sidebar._
|
||||
- **Sidebar-Admin-Submenu kontextbewusst.** Die Admin-Untereinträge
|
||||
klappen nur noch auf, wenn man tatsächlich auf einer
|
||||
`/admin/*`-Route ist. Auf allen anderen Seiten bleibt die
|
||||
Admin-Gruppe eingeklappt.
|
||||
_The sidebar Admin sub-items only expand when you are actually on
|
||||
an `/admin/*` route. Everywhere else the Admin group stays
|
||||
collapsed._
|
||||
- **Stimmung-Chart aggregiert wie die anderen Metriken.** Über 90
|
||||
Tage wechselt das Stimmung-Diagramm automatisch auf wöchentliche
|
||||
Aggregation, über 730 Tage auf monatliche — analog zu Gewicht und
|
||||
Blutdruck. Im Header zeigt ein Chip die aktive Aggregationsstufe.
|
||||
_The mood chart now auto-aggregates: weekly past 90 days, monthly
|
||||
past 730 days — same thresholds as weight and BP. A chip in the
|
||||
chart header shows the active aggregation level._
|
||||
- **KI-Insights mit harten Anti-Halluzinations-Leitplanken.** Die
|
||||
Provider-Antwort wird gegen ein striktes Zod-Schema geprüft
|
||||
(Zusammenfassung, Empfehlungen mit Pflichtfeld `metricSource`,
|
||||
Zitate, Warnungen). Empfehlungen, die Datenpunkte zitieren, die
|
||||
nicht in der Snapshot-Liste stehen, werden abgewiesen. Der
|
||||
Wrapper retried genau einmal mit korrigierender Systemnachricht;
|
||||
scheitert auch der zweite Versuch, kommt 422 zurück statt
|
||||
unsauberen Ausgaben. Slug-Drift im Codex-Backend (z. B. Wechsel
|
||||
`gpt-5-codex` → `gpt-5.3-codex`) ist jetzt durch eine
|
||||
konfigurierbare Fallback-Kette + 1-Stunden-Cache abgefedert; alle
|
||||
Slugs durch → strukturierter 503 mit `attempted[]`-Liste.
|
||||
_AI insights gain hard anti-hallucination guardrails. Provider
|
||||
responses are validated against a strict Zod schema (summary,
|
||||
recommendations with mandatory `metricSource`, citations,
|
||||
warnings). Recommendations citing data points not present in the
|
||||
snapshot are rejected. The wrapper retries exactly once with a
|
||||
corrective system message; if the second attempt also fails, it
|
||||
returns 422 instead of unsanitised output. Codex-backend slug
|
||||
drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now cushioned by
|
||||
a configurable fallback chain + 1-hour positive cache; if every
|
||||
slug fails the route returns a structured 503 with the
|
||||
`attempted[]` list._
|
||||
- **`/admin/api-tokens`-Tabelle mobil.** Auf schmalen Viewports
|
||||
blenden Spalten gestaffelt aus (zuletzt benutzt, erstellt am,
|
||||
Owner) und der Owner-Username wandert inline neben den Token-
|
||||
Namen, damit beim Ausblenden keine Information verloren geht.
|
||||
_The `/admin/api-tokens` table is responsive on mobile. Narrow
|
||||
viewports gate columns behind breakpoints (last-used, created-at,
|
||||
owner) and the owner username falls back inline next to the token
|
||||
name so hiding the column never drops data._
|
||||
- **Stimmung-Kachel mobil.** Auf schmalen Viewports zeigt die
|
||||
Mood-Karte oben jetzt nur noch die große Score-Zahl + Label statt
|
||||
der doppelten Darstellung von vorher.
|
||||
_Mood tile on mobile shows only the large score number + label,
|
||||
no more doubled rendering._
|
||||
- **Quick-Add-Submenu eindeutiger.** Die beiden Einträge im
|
||||
Quick-Add-Menü heißen jetzt „Messung erfassen“ und „Stimmung
|
||||
erfassen“ (DE) bzw. „Log measurement“ und „Log mood“ (EN) —
|
||||
vorher beide schlicht „Hinzufügen“.
|
||||
_Quick-add submenu disambiguated. The two entries are now called
|
||||
“Messung erfassen” / “Stimmung erfassen” (DE) and “Log
|
||||
measurement” / “Log mood” (EN) — previously both simply “Add”._
|
||||
- The `/admin` overview replaces the section grid with an audit-log
|
||||
preview (recent entries, actor, target resource) and a system-status
|
||||
snapshot. The section grid moved into the sidebar.
|
||||
- The sidebar Admin sub-items only expand when you are actually on an
|
||||
`/admin/*` route. Everywhere else the Admin group stays collapsed.
|
||||
- The mood chart now auto-aggregates: weekly past 90 days, monthly past
|
||||
730 days — same thresholds as weight and BP. A chip in the chart header
|
||||
shows the active aggregation level.
|
||||
- AI insights gain hard anti-hallucination guardrails. Provider responses
|
||||
are validated against a strict Zod schema (summary, recommendations with
|
||||
mandatory `metricSource`, citations, warnings). Recommendations citing
|
||||
data points not present in the snapshot are rejected. The wrapper
|
||||
retries exactly once with a corrective system message; if the second
|
||||
attempt also fails, it returns 422 instead of unsanitised output.
|
||||
Codex-backend slug drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now
|
||||
cushioned by a configurable fallback chain + 1-hour positive cache; if
|
||||
every slug fails the route returns a structured 503 with the
|
||||
`attempted[]` list.
|
||||
- The `/admin/api-tokens` table is responsive on mobile. Narrow viewports
|
||||
gate columns behind breakpoints (last-used, created-at, owner) and the
|
||||
owner username falls back inline next to the token name so hiding the
|
||||
column never drops data.
|
||||
- Mood tile on mobile shows only the large score number + label, no more
|
||||
doubled rendering.
|
||||
- Quick-add submenu disambiguated. The two entries are now called “Messung
|
||||
erfassen” / “Stimmung erfassen” (DE) and “Log measurement” / “Log mood”
|
||||
(EN) — previously both simply “Add”.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blutdruck-Zielbereich-Prozent.** Die Zielbereich-Kachel zählte
|
||||
Sys/Dia-Paare nicht korrekt, sobald durch Importe die Sys/Dia-
|
||||
Zeitstempel weiter als 5 Minuten auseinander lagen — Ergebnis
|
||||
konnte 0 % anzeigen, obwohl die meisten Werte im Zielbereich
|
||||
waren. Fix: Tagesschlüssel-Fallback (gleicher Berliner Tag) +
|
||||
Paaranzahl als Nenner.
|
||||
_Blood-pressure target-range percentage. The target-range tile
|
||||
miscounted sys/dia pairs once import drift pushed sys/dia
|
||||
timestamps more than 5 minutes apart — the tile could show 0 %
|
||||
even though most readings were inside the target range. Fix: a
|
||||
same-Berlin-day key fallback + pair-count as denominator._
|
||||
- **Onboarding-Flackern.** Auf bereits abgeschlossenen Accounts
|
||||
rendert die Onboarding-Karte nicht mehr für ~500 ms, bevor sie
|
||||
wieder verschwindet. Sie wird erst gemountet, sobald der
|
||||
Analytics-Status geladen ist, und öffnet sich nicht mehr
|
||||
automatisch, wenn alles erledigt ist.
|
||||
_Onboarding flicker. On accounts where onboarding is already
|
||||
complete, the onboarding card no longer renders for ~500 ms
|
||||
before vanishing. It mounts only after the analytics status has
|
||||
loaded, and refuses to auto-open when nothing is left to do._
|
||||
- **Skip-Link blockiert Logo nicht.** Der „Zum Inhalt
|
||||
springen“-Skip-Link liegt zwar weiterhin am Anfang des Tabbing-
|
||||
Reihenfolge, blockiert aber nicht mehr Klicks aufs Logo.
|
||||
_The “Skip to content” skip-link still leads the tab order but
|
||||
no longer blocks clicks on the logo._
|
||||
- **Bug-Report-Eintrag richtet sich nach dem Admin-Toggle.** Wenn
|
||||
der Admin den Bug-Report deaktiviert hat, verschwindet der
|
||||
Eintrag aus Sidebar, Bottom-Nav, Topbar und der Fehler-Detail-
|
||||
„Bug melden“-Schaltfläche.
|
||||
_The “Report a bug” entry follows the admin feature flag. With
|
||||
bug reporting disabled, the entry vanishes from the sidebar,
|
||||
bottom-nav, topbar, and the error-detail “Report bug” button._
|
||||
- **Feedback-Link richtet sich nach dem Admin-Toggle.** Wenn die
|
||||
Feedback-Sammlung deaktiviert ist, ist auch der UI-Einstieg
|
||||
weg.
|
||||
_The Feedback link follows its admin feature flag. With feedback
|
||||
disabled, the UI entry point disappears too._
|
||||
- **Notification-Channels deaktivieren sich auf harte Reject-
|
||||
Fehler.** Antworten Telegram / ntfy / Web Push wiederholt mit
|
||||
410 oder anderen permanenten Reject-Codes, deaktiviert sich der
|
||||
Kanal automatisch und schreibt einen Audit-Log-Eintrag. Status
|
||||
- Re-Enable-Button finden sich in Settings → Benachrichtigungen.
|
||||
_Notification channels auto-disable on persistent hard rejects.
|
||||
If Telegram / ntfy / Web Push respond repeatedly with 410 or
|
||||
other permanent reject codes, the channel disables itself and
|
||||
writes an audit-log entry. Status + re-enable button live in
|
||||
Settings → Notifications._
|
||||
- **Refresh-Token-Fehler markiert Integration als reauth-pflichtig.**
|
||||
Bricht der Withings- oder moodLog-Refresh-Token-Tausch ab
|
||||
(typischerweise nach 90 Tagen ohne Re-Auth), wechselt die
|
||||
Integration in `error_reauth` und der Settings-Eintrag bittet um
|
||||
Neu-Verbindung — statt jeden Sync-Tick weiter gegen den Provider
|
||||
zu hämmern.
|
||||
_Refresh-token failures flip the integration to “needs reauth”.
|
||||
When the Withings or moodLog refresh-token exchange fails
|
||||
(typically after 90 days without re-auth), the integration
|
||||
switches to `error_reauth` and the Settings entry asks for
|
||||
re-connect — instead of hammering the upstream every sync
|
||||
tick._
|
||||
- **Charts blockieren das Wischen nicht mehr.** Auf Touch-Geräten
|
||||
fingen die Recharts-Wrapper vertikales Wischen ab; das fühlte
|
||||
sich auf langsamen Geräten wie Hänger an. Fix: `touch-action:
|
||||
pan-y` auf den Chart-Containern, vertikaler Scroll geht jetzt
|
||||
durch.
|
||||
_Mobile chart containers no longer eat vertical scroll. On touch
|
||||
devices the Recharts wrappers swallowed vertical pans; on slower
|
||||
devices that felt like a scroll lockup. Fix: `touch-action:
|
||||
pan-y` on the chart wrappers, vertical scroll passes through
|
||||
again._
|
||||
- **`/admin/users` mobil ohne Overflow.** Auf schmalen Viewports
|
||||
rendert die User-Tabelle als Card-List statt einer
|
||||
horizontal-scrollenden Tabelle.
|
||||
_`/admin/users` on mobile renders as a card list instead of a
|
||||
horizontally-scrolling table._
|
||||
- **A11y-Verbesserungen.** Chart-Range-Buttons, Medikamenten-
|
||||
Primary-Buttons, Mood-List-Mobile-Icon-Buttons, Login-CTAs,
|
||||
Settings → Account-Passkey-Tabelle, Onboarding-Tour-Focus-Trap +
|
||||
DE-Overflow + Backdrop-Ring — alles auf 44 px Mindest-
|
||||
Tap-Target bzw. mit `aria-label` / Keyboard-Bedienbarkeit
|
||||
versehen.
|
||||
_Accessibility round of fixes: chart range buttons, medication
|
||||
primary buttons, mood-list mobile icon buttons, login CTAs,
|
||||
Settings → Account passkey table, onboarding-tour focus trap +
|
||||
DE-locale overflow + backdrop ring — all aligned to 44 px tap
|
||||
targets and labelled / keyboard-navigable._
|
||||
- Blood-pressure target-range percentage. The target-range tile miscounted
|
||||
sys/dia pairs once import drift pushed sys/dia timestamps more than 5
|
||||
minutes apart — the tile could show 0 % even though most readings were
|
||||
inside the target range. Fix: a same-Berlin-day key fallback +
|
||||
pair-count as denominator.
|
||||
- Onboarding flicker. On accounts where onboarding is already complete,
|
||||
the onboarding card no longer renders for ~500 ms before vanishing. It
|
||||
mounts only after the analytics status has loaded, and refuses to
|
||||
auto-open when nothing is left to do.
|
||||
- The “Skip to content” skip-link still leads the tab order but no longer
|
||||
blocks clicks on the logo.
|
||||
- The “Report a bug” entry follows the admin feature flag. With bug
|
||||
reporting disabled, the entry vanishes from the sidebar, bottom-nav,
|
||||
topbar, and the error-detail “Report bug” button.
|
||||
- The Feedback link follows its admin feature flag. With feedback
|
||||
disabled, the UI entry point disappears too.
|
||||
- Notification channels auto-disable on persistent hard rejects. If
|
||||
Telegram / ntfy / Web Push respond repeatedly with 410 or other
|
||||
permanent reject codes, the channel disables itself and writes an
|
||||
audit-log entry. Status + re-enable button live in Settings →
|
||||
Notifications.
|
||||
- reauth` und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden
|
||||
Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures
|
||||
flip the integration to “needs reauth”. When the Withings or moodLog
|
||||
refresh-token exchange fails (typically after 90 days without re-auth),
|
||||
the integration switches to `error_reauth` and the Settings entry asks
|
||||
for re-connect — instead of hammering the upstream every sync tick.
|
||||
- Mobile chart containers no longer eat vertical scroll. On touch devices
|
||||
the Recharts wrappers swallowed vertical pans; on slower devices that
|
||||
felt like a scroll lockup. Fix: `touch-action: pan-y` on the chart
|
||||
wrappers, vertical scroll passes through again.
|
||||
- `/admin/users` on mobile renders as a card list instead of a
|
||||
horizontally-scrolling table.
|
||||
- Accessibility round of fixes: chart range buttons, medication primary
|
||||
buttons, mood-list mobile icon buttons, login CTAs, Settings → Account
|
||||
passkey table, onboarding-tour focus trap + DE-locale overflow +
|
||||
backdrop ring — all aligned to 44 px tap targets and labelled /
|
||||
keyboard-navigable.
|
||||
|
||||
### Security
|
||||
|
||||
- **AI-Provider-Antworten gegen striktes Schema validiert.** Der
|
||||
Insight-Wrapper akzeptiert nur Antworten, die das strikte
|
||||
`aiInsightResponseSchema` erfüllen, und verweigert Empfehlungen,
|
||||
die Datenpunkte zitieren, die nicht im übergebenen Snapshot
|
||||
stehen. Schemafehler triggern einen einmaligen Retry mit
|
||||
korrigierender Systemnachricht; ein zweiter Fehler liefert 422
|
||||
statt halluzinierter Ausgaben.
|
||||
_AI provider responses are validated against a strict Zod
|
||||
schema. The insight wrapper only accepts responses that satisfy
|
||||
the strict `aiInsightResponseSchema`, and rejects recommendations
|
||||
citing data points absent from the supplied snapshot. Schema
|
||||
failures trigger a single retry with a corrective system message;
|
||||
a second failure returns 422 instead of hallucinated output._
|
||||
- **Backup-Operationen vollständig auditiert.** Run, Download,
|
||||
Upload und Restore — inklusive Start-Marker, Denial-Reasons und
|
||||
Failures — schreiben jeweils einen Audit-Log-Eintrag mit Akteur
|
||||
und Snapshot-ID. Restore wird zusätzlich durch fünf unabhängige
|
||||
Gates abgesichert (Cookie-Auth-only-Admin, `confirm: "RESTORE"`-
|
||||
Body, getypte UI-Bestätigung, Idempotency-Key, Pre-Tx-Enum-
|
||||
Validation).
|
||||
_Backup operations are fully audited. Run, download, upload, and
|
||||
restore — including start markers, denial reasons, and failures
|
||||
— each write an audit-log entry with actor and snapshot ID.
|
||||
Restore is additionally protected by five independent gates
|
||||
(cookie-only admin auth, `confirm: "RESTORE"` body, typed UI
|
||||
confirmation, idempotency-key wrap, pre-transaction enum
|
||||
validation)._
|
||||
- AI provider responses are validated against a strict Zod schema. The
|
||||
insight wrapper only accepts responses that satisfy the strict
|
||||
`aiInsightResponseSchema`, and rejects recommendations citing data
|
||||
points absent from the supplied snapshot. Schema failures trigger a
|
||||
single retry with a corrective system message; a second failure returns
|
||||
422 instead of hallucinated output.
|
||||
- Backup operations are fully audited. Run, download, upload, and restore
|
||||
— including start markers, denial reasons, and failures — each write an
|
||||
audit-log entry with actor and snapshot ID. Restore is additionally
|
||||
protected by five independent gates (cookie-only admin auth, `confirm:
|
||||
"RESTORE"` body, typed UI confirmation, idempotency-key wrap,
|
||||
pre-transaction enum validation).
|
||||
|
||||
## [1.4.14] — 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Admin-Bereich nach Sektionen aufgeteilt.** Statt einer monolithischen
|
||||
`/admin`-Seite mit Anker-Sprüngen hat jede Sektion jetzt ihre eigene
|
||||
Route: `/admin/system-status`, `/admin/services`,
|
||||
`/admin/integrations`, `/admin/feedback`, `/admin/reminders`,
|
||||
`/admin/users`, `/admin/api-tokens`, `/admin/login-overview`,
|
||||
`/admin/backups`, `/admin/danger-zone`. Sidebar bekommt eine
|
||||
ausklappbare Admin-Gruppe; Status-Cards verlinken direkt auf die
|
||||
passende Unterseite. Alte `/admin#section-…`-Pfade werden serverseitig
|
||||
weitergeleitet.
|
||||
_The admin area is split into per-section pages instead of one
|
||||
monolithic dashboard with anchor jumps. Each section has its own URL
|
||||
- The admin area is split into per-section pages instead of one monolithic
|
||||
dashboard with anchor jumps. Each section has its own URL
|
||||
(`/admin/system-status`, `/admin/services`, `/admin/integrations`,
|
||||
`/admin/feedback`, `/admin/reminders`, `/admin/users`,
|
||||
`/admin/api-tokens`, `/admin/login-overview`, `/admin/backups`,
|
||||
`/admin/danger-zone`); the sidebar grows an expandable Admin group;
|
||||
status cards link to the relevant sub-page; legacy
|
||||
`/admin#section-…` paths are redirected server-side._
|
||||
- **Neue Backups-Sektion** unter `/admin/backups` mit Tabelle aller
|
||||
bisherigen Sicherungen (Größe, Typ, Erstellungszeit) und einem
|
||||
„Backup jetzt erstellen“-Button, der einen Ad-hoc-Job auf der
|
||||
pg-boss-Queue auslöst.
|
||||
_New `/admin/backups` view with a table of all stored backups (size,
|
||||
status cards link to the relevant sub-page; legacy `/admin#section-…`
|
||||
paths are redirected server-side.
|
||||
- New `/admin/backups` view with a table of all stored backups (size,
|
||||
type, timestamp) and a “Backup now” button that enqueues an ad-hoc
|
||||
pg-boss job._
|
||||
- **Neue User-Verwaltung** unter `/admin/users` mit Filter-Pills
|
||||
(alle / nur Admins / nur Standardnutzer) und einer
|
||||
„Force-Logout“-Aktion pro Nutzer hinter einem Bestätigungsdialog
|
||||
(löscht alle aktiven Sessions des Ziels und schreibt einen
|
||||
Audit-Log-Eintrag). Selbst-Logout ist gesperrt.
|
||||
_New `/admin/users` view with role filter pills (all / admins only /
|
||||
pg-boss job.
|
||||
- New `/admin/users` view with role filter pills (all / admins only /
|
||||
users only) and a per-row force-logout action behind a confirmation
|
||||
dialog (deletes every active session of the target and writes an
|
||||
audit-log entry). Self-target is disabled._
|
||||
- **„Gespeicherten KI-Schlüssel entfernen“** in Settings → KI: ein
|
||||
klar betitelter Knopf mit Bestätigungsdialog löscht den
|
||||
gespeicherten OpenAI- bzw. lokalen Provider-Key, ohne Settings
|
||||
ansonsten anzufassen.
|
||||
_New “Remove saved AI key” button in Settings → AI: a clearly
|
||||
labelled button behind a confirmation dialog clears the stored
|
||||
OpenAI or local provider key without touching anything else._
|
||||
- **`CODEX_MODEL`-Env-Var** als Operator-Override für das Codex-
|
||||
Modell-Slug, damit alternative Modelle ohne Rebuild getestet werden
|
||||
können (z. B. wenn dein ChatGPT-Plan ein anderes Default-Modell
|
||||
bevorzugt).
|
||||
_New `CODEX_MODEL` env var lets operators override the Codex model
|
||||
slug without a rebuild — useful for testing alternate slugs against
|
||||
different ChatGPT plan tiers._
|
||||
audit-log entry). Self-target is disabled.
|
||||
- New “Remove saved AI key” button in Settings → AI: a clearly labelled
|
||||
button behind a confirmation dialog clears the stored OpenAI or local
|
||||
provider key without touching anything else.
|
||||
- MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit
|
||||
alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein
|
||||
ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL`
|
||||
env var lets operators override the Codex model slug without a rebuild —
|
||||
useful for testing alternate slugs against different ChatGPT plan tiers.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Trend-Pfeil-Farben sind jetzt metrik-bewusst.** Steigende Tendenz
|
||||
bedeutet je nach Messwert etwas anderes: Blutdruck und Gewicht ↑ =
|
||||
orange (Warnung), Stimmung ↑ = grün (positiv), Puls ↑ = neutral
|
||||
(kontextabhängig). Die Pfeil-Richtung selbst bleibt unverändert.
|
||||
_Trend-arrow colors are metric-aware: BP and weight rising = orange
|
||||
- Trend-arrow colors are metric-aware: BP and weight rising = orange
|
||||
(warning), mood rising = green (positive), pulse rising = neutral
|
||||
(context-dependent). Arrow direction itself is unchanged._
|
||||
- **Wipe-all-data deckt jetzt auch Notification-Channels und Web-Push-
|
||||
Subscriptions ab.** Verschlüsselte Telegram-Bot-Tokens und
|
||||
Web-Push-Endpoints überleben einen vollständigen Account-Wipe nicht
|
||||
länger. Feedback und Audit-Log bleiben erhalten (wie bisher).
|
||||
Bestätigungstext im UI nennt den neuen Umfang explizit.
|
||||
_“Wipe all data” now also clears notification channels and web-push
|
||||
subscriptions: encrypted Telegram bot tokens and web-push endpoints
|
||||
no longer survive a full account wipe. Feedback and audit log stay
|
||||
untouched (unchanged). The confirmation copy spells out the new
|
||||
scope._
|
||||
- **i18n-Schlüssel im Admin-Bereich** unter `admin.section.<slug>.*`
|
||||
reorganisiert (EN + DE-Parität).
|
||||
_Admin-area i18n keys reorganised under `admin.section.<slug>.*`
|
||||
(EN + DE parity)._
|
||||
(context-dependent). Arrow direction itself is unchanged.
|
||||
- “Wipe all data” now also clears notification channels and web-push
|
||||
subscriptions: encrypted Telegram bot tokens and web-push endpoints no
|
||||
longer survive a full account wipe. Feedback and audit log stay
|
||||
untouched (unchanged). The confirmation copy spells out the new scope.
|
||||
- Admin-area i18n keys reorganised under `admin.section.<slug>.*` (EN + DE
|
||||
parity).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex/ChatGPT-Modell-Slug korrigiert.** Der Codex-Backend-Endpoint
|
||||
lehnt sowohl `gpt-5-codex` als auch `gpt-5` für ChatGPT-Account-
|
||||
Auth ab; das tatsächliche, codex-optimierte Slug für die Plus/Pro-
|
||||
Tarife heißt `gpt-5.3-codex`. v1.4.14 verdrahtet den richtigen
|
||||
Default. „Verbindung testen“ in Settings → KI und der Insight-
|
||||
Generator laufen damit beide gegen dein ChatGPT-Abo durch.
|
||||
_Codex/ChatGPT model slug corrected: the Codex backend rejects both
|
||||
`gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account;
|
||||
the codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`.
|
||||
v1.4.14 wires the correct default. Settings → AI “Test connection”
|
||||
and the insight generator now both succeed against your ChatGPT
|
||||
subscription._
|
||||
- Codex/ChatGPT model slug corrected: the Codex backend rejects both
|
||||
`gpt-5-codex` and `gpt-5` when authenticated via a ChatGPT account; the
|
||||
codex-optimised slug for the Plus/Pro tiers is `gpt-5.3-codex`. v1.4.14
|
||||
wires the correct default. Settings → AI “Test connection” and the
|
||||
insight generator now both succeed against your ChatGPT subscription.
|
||||
- **Sommerzeit-Wechsel verschiebt keine Tageswerte mehr.** Die
|
||||
Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in
|
||||
reinem UTC und verlor an den Berliner DST-Grenzen einen Tag. Die
|
||||
@@ -951,88 +942,49 @@ pan-y` on the chart wrappers, vertical scroll passes through
|
||||
helper anchors at Berlin Y-M-D and subtracts in 86_400_000-ms
|
||||
steps; bucket pairing across blood pressure, weight, and mood is
|
||||
now DST-safe.*
|
||||
- **KI-Provider-Fehler werden korrekt klassifiziert.**
|
||||
`/api/insights/generate` antwortet auf 401/403-Fehler des Providers
|
||||
jetzt mit 422 („KI-Anbieter hat den Request abgelehnt — bitte
|
||||
API-Key in Settings → KI prüfen“), auf 5xx mit 503 („KI-Anbieter
|
||||
vorübergehend nicht erreichbar“) und auf 429 mit 429. Vorher kam
|
||||
pauschal 500. Volle Fehlerdetails landen weiter im Wide-Event-Log.
|
||||
_AI provider errors are now correctly classified by
|
||||
`/api/insights/generate`: provider 401/403 → 422 (“AI provider
|
||||
rejected the request — check your API key in Settings → AI”),
|
||||
provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → 429. Previously everything returned 500. Full error context still
|
||||
lands in the structured logs._
|
||||
- **Logging-Redactor schluckt keine harmlosen Wörter mehr.** Der
|
||||
Secret-Redactor maskierte zuvor jedes Vorkommen von `sk-…` — auch
|
||||
in `task-force`, `risk-management`, `disk-io`. Die neue Regex
|
||||
verlangt eine Wortgrenze und mindestens 8 Folgezeichen, das Padding
|
||||
bleibt für echte API-Keys (`sk-…`, `sk-ant-…`) intakt.
|
||||
_The structured-logging secret redactor no longer mangles innocent
|
||||
words. The previous regex matched any `sk-…` substring and masked
|
||||
fragments inside `task-force`, `risk-management`, `disk-io`. The
|
||||
new pattern requires a word boundary and at least 8 trailing
|
||||
characters, while still scrubbing real API keys (`sk-…`,
|
||||
`sk-ant-…`)._
|
||||
- AI provider errors are now correctly classified by
|
||||
`/api/insights/generate`: provider 401/403 → 422 (“AI provider rejected
|
||||
the request — check your API key in Settings → AI”), provider 5xx → 503
|
||||
(“AI provider temporarily unavailable”), 429 → 429. Previously
|
||||
everything returned 500. Full error context still lands in the
|
||||
structured logs.
|
||||
- The structured-logging secret redactor no longer mangles innocent words.
|
||||
The previous regex matched any `sk-…` substring and masked fragments
|
||||
inside `task-force`, `risk-management`, `disk-io`. The new pattern
|
||||
requires a word boundary and at least 8 trailing characters, while still
|
||||
scrubbing real API keys (`sk-…`, `sk-ant-…`).
|
||||
|
||||
### Performance
|
||||
|
||||
- **Insights-Seite startet schneller.** Die Recharts-Symbole für die
|
||||
Korrelations-Scatter-Plots werden jetzt bedarfsweise per
|
||||
`next/dynamic` geladen statt eager mitausgeliefert; das spart
|
||||
ca. 108 KiB initiales JavaScript auf der Insights-Seite. Die
|
||||
Chart-Optik bleibt unverändert.
|
||||
_Bundle-size improvements on the insights page: Recharts symbols
|
||||
for the correlation scatter plots are now loaded on demand via
|
||||
`next/dynamic` instead of being shipped eagerly, saving roughly
|
||||
108 KiB of initial JavaScript on `/insights`. Chart visuals are
|
||||
unchanged._
|
||||
- **Dashboard fragt die Onboarding-Checkliste nicht mehr ab, wenn
|
||||
sie schon abgeschlossen ist.** Sobald `onboardingCompletedAt`
|
||||
gesetzt ist, entfallen die `withings/status`- und
|
||||
`notifications/preferences`-Calls beim Dashboard-Aufruf. Spart
|
||||
rund 950 ms Netzwerkzeit bei wiederkehrenden Nutzern.
|
||||
_The dashboard skips the onboarding-checklist API requests once
|
||||
- Bundle-size improvements on the insights page: Recharts symbols for the
|
||||
correlation scatter plots are now loaded on demand via `next/dynamic`
|
||||
instead of being shipped eagerly, saving roughly 108 KiB of initial
|
||||
JavaScript on `/insights`. Chart visuals are unchanged.
|
||||
- The dashboard skips the onboarding-checklist API requests once
|
||||
onboarding is complete. With `onboardingCompletedAt` set, the
|
||||
`withings/status` and `notifications/preferences` queries no longer
|
||||
fire on dashboard load — saves about 950 ms of network time for
|
||||
established users._
|
||||
`withings/status` and `notifications/preferences` queries no longer fire
|
||||
on dashboard load — saves about 950 ms of network time for established
|
||||
users.
|
||||
|
||||
### Tests & A11y
|
||||
|
||||
- **Erweiterte End-to-End-Suite.** Neue Playwright-Specs decken den
|
||||
authentifizierten Dashboard-Render, den „Messung anlegen“-Flow,
|
||||
den Doctor-Report-PDF-Download, einen gemockten Codex-Connect-
|
||||
Flow, einen gemockten Insights-Generate-Flow und einen
|
||||
Mobile-Viewport-Smoketest (Pixel 5) ab.
|
||||
_Extended end-to-end suite. New Playwright specs cover the
|
||||
authenticated dashboard render, the “add measurement” flow, the
|
||||
doctor-report PDF download, a mocked Codex connect flow, a mocked
|
||||
insights-generate flow, and a mobile-viewport smoke test (Pixel 5)._
|
||||
- **Axe-core-Audit auf allen Schlüsselrouten grün.** Die ernsten
|
||||
und kritischen Verstöße auf `/dashboard`, `/settings/integrations`
|
||||
und `/admin` (inkl. `/admin/system-status` und `/admin/users`)
|
||||
wurden behoben — fehlende `aria-label`s an Icon-Buttons, fehlende
|
||||
Namen am Mobile-User-Menü-Trigger, leere `<dd>`-Platzhalter,
|
||||
Login-Link nur durch Farbe erkennbar, Quick-Add-Menü-Items mit
|
||||
doppeltem Namen.
|
||||
_Axe-core accessibility audit now clean of serious/critical
|
||||
violations on `/dashboard`, `/settings/integrations`, and `/admin`
|
||||
(incl. `/admin/system-status`, `/admin/users`). Fixes: missing
|
||||
aria-labels on icon-only buttons, unnamed mobile user-menu trigger,
|
||||
empty `<dd>` placeholders, sign-up link distinguishable only by
|
||||
color, quick-add menu with two indistinguishable items._
|
||||
- Extended end-to-end suite. New Playwright specs cover the authenticated
|
||||
dashboard render, the “add measurement” flow, the doctor-report PDF
|
||||
download, a mocked Codex connect flow, a mocked insights-generate flow,
|
||||
and a mobile-viewport smoke test (Pixel 5).
|
||||
- Axe-core accessibility audit now clean of serious/critical violations on
|
||||
`/dashboard`, `/settings/integrations`, and `/admin` (incl.
|
||||
`/admin/system-status`, `/admin/users`). Fixes: missing aria-labels on
|
||||
icon-only buttons, unnamed mobile user-menu trigger, empty `<dd>`
|
||||
placeholders, sign-up link distinguishable only by color, quick-add menu
|
||||
with two indistinguishable items.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Dokumentations-Site auf v1.4.14 aktualisiert** — Codex-Device-
|
||||
Code-Pfad neu beschrieben, neue Admin-Sektionen (Backups, Users)
|
||||
dokumentiert, `CODEX_MODEL`-Env-Var aufgenommen,
|
||||
Dashboard-Performance-Hinweis ergänzt, Codex-Troubleshooting-Block
|
||||
überarbeitet.
|
||||
_Documentation site brought up to date with v1.4.14 — Codex
|
||||
device-code flow rewritten, new Admin sections (Backups, Users)
|
||||
documented, `CODEX_MODEL` env var added, dashboard performance note
|
||||
added, Codex troubleshooting block revised._
|
||||
- Documentation site brought up to date with v1.4.14 — Codex device-code
|
||||
flow rewritten, new Admin sections (Backups, Users) documented,
|
||||
`CODEX_MODEL` env var added, dashboard performance note added, Codex
|
||||
troubleshooting block revised.
|
||||
|
||||
## [1.4.13] — 2026-05-09
|
||||
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -78,7 +78,7 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
- **shadcn/ui** components (new-york style) for UI primitives.
|
||||
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
|
||||
|
||||
See `CLAUDE.md` for detailed architecture and patterns.
|
||||
See `CONTRIBUTING-AI.md` for detailed architecture and patterns.
|
||||
|
||||
## Database Changes
|
||||
|
||||
|
||||
@@ -167,9 +167,31 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
// Walk every element inside the mobile card-list and assert no
|
||||
// child has a wider scrollWidth than its container. This is the
|
||||
// Pixel-5 worst-case Marc kept hitting.
|
||||
//
|
||||
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
|
||||
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
|
||||
// design `scrollWidth > clientWidth` whenever the underlying text
|
||||
// is longer than the parent column — that IS the truncation, not
|
||||
// an overflow leak. Skip those so the assertion only fires on real
|
||||
// layout culprits (a span without a width clamp pushing its parent
|
||||
// wider). The page-level `dims.scrollWidth <= dims.innerWidth + 1`
|
||||
// assertion below remains the authoritative no-scrollbar guard.
|
||||
const culprits = await page
|
||||
.getByTestId("admin-tokens-mobile-list")
|
||||
.evaluate((root) => {
|
||||
const isTruncated = (el: Element): boolean => {
|
||||
const cs = window.getComputedStyle(el);
|
||||
if (cs.textOverflow === "ellipsis" && cs.overflowX !== "visible") {
|
||||
return true;
|
||||
}
|
||||
// Tailwind's `truncate` shorthand and any explicit overflow
|
||||
// clamp also legitimately keep the visible content inside
|
||||
// clientWidth even though scrollWidth measures full content.
|
||||
if (cs.overflowX === "hidden" || cs.overflowX === "clip") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const out: Array<{
|
||||
tag: string;
|
||||
klass: string;
|
||||
@@ -177,7 +199,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
clientWidth: number;
|
||||
}> = [];
|
||||
for (const el of root.querySelectorAll("*")) {
|
||||
if (el.scrollWidth > el.clientWidth + 1) {
|
||||
if (el.scrollWidth > el.clientWidth + 1 && !isTruncated(el)) {
|
||||
out.push({
|
||||
tag: el.tagName,
|
||||
klass:
|
||||
|
||||
@@ -103,17 +103,28 @@ test.describe("chart overlay controls", () => {
|
||||
.first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Scroll the trigger into view before opening the dropdown so the
|
||||
// Radix Popper places its portalled content inside the viewport.
|
||||
// On the seeded test user the dashboard renders multiple stacked
|
||||
// chart cards, and the first cog can sit near the bottom edge of
|
||||
// the desktop 1280×720 viewport — opening the popover then drops
|
||||
// the toggle below the fold and Playwright refuses to click an
|
||||
// off-viewport element after auto-scrolling.
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
await trigger.click();
|
||||
|
||||
// The popover content paints three switches.
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-overlay-toggle-target-range"]'),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
const targetRangeToggle = page.locator(
|
||||
'[data-slot="chart-overlay-toggle-target-range"]',
|
||||
);
|
||||
await expect(targetRangeToggle).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Toggle target-range on. Radix Switch is a button[role=switch].
|
||||
await page
|
||||
.locator('[data-slot="chart-overlay-toggle-target-range"]')
|
||||
.click();
|
||||
// Force the click — `scrollIntoViewIfNeeded()` on the portalled
|
||||
// dropdown content is a no-op (the Radix portal sits at the body
|
||||
// root, outside the page scroll container) so we bypass Playwright's
|
||||
// viewport check directly.
|
||||
await targetRangeToggle.click({ force: true });
|
||||
|
||||
// The PUT fires once for the toggle change.
|
||||
await expect.poll(() => putRequestCount).toBeGreaterThan(0);
|
||||
|
||||
@@ -198,8 +198,24 @@ test.describe("charts — mobile-viewport regression", () => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for at least one Recharts SVG to mount.
|
||||
await page.locator(".recharts-xAxis").first().waitFor({ timeout: 10_000 });
|
||||
// Wait for at least one chart card to mount, then scroll it into
|
||||
// view so Recharts' ResponsiveContainer measures its parent and
|
||||
// paints the SVG. On Pixel 5 the dashboard ships several stacked
|
||||
// tiles above the first chart card; the bottom-positioned axis
|
||||
// sits below the initial fold and reports as `hidden` to a raw
|
||||
// `.recharts-xAxis` visibility probe before scroll.
|
||||
const firstChartCard = page
|
||||
.locator("div.bg-card")
|
||||
.filter({ has: page.locator("[data-slot=chart-range-tab]") })
|
||||
.first();
|
||||
await firstChartCard.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await firstChartCard.scrollIntoViewIfNeeded();
|
||||
// After scroll, give the chart a beat to lay out under the new
|
||||
// measured size before counting ticks.
|
||||
await page
|
||||
.locator(".recharts-xAxis .recharts-cartesian-axis-tick text")
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
// Recharts lays its visible tick labels under
|
||||
// `.recharts-xAxis .recharts-cartesian-axis-tick`. Count the ones
|
||||
|
||||
@@ -215,11 +215,15 @@ test.describe("insights advisor card on /insights (C1)", () => {
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Hero + advisor card both render. The hero is from B1b; the
|
||||
// advisor card is the C1 deliverable.
|
||||
await expect(page.locator('[data-slot="insights-page-hero"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Hero + advisor card both render. v1.4.20 B1 replaced the legacy
|
||||
// `<InsightsPageHero>` (data-slot="insights-page-hero") with the
|
||||
// condensed `<HeroStrip>` (data-slot="insights-hero-strip"); the
|
||||
// advisor card is the C1 deliverable below it.
|
||||
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// The recommendation card surface from B5c — `data-slot` markers
|
||||
// are pinned in tests, look for the rec text the mock provided.
|
||||
|
||||
@@ -133,10 +133,21 @@ test.describe("dashboard onboarding card flicker guard", () => {
|
||||
test("incomplete-onboarding user sees the card collapsed by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Onboarding is incomplete AND profile fields are missing → the
|
||||
// `shouldShowChecklist` gate is true. With the fix, the card mounts
|
||||
// collapsed: header + progress meter visible, full row list hidden
|
||||
// until the user clicks the chevron.
|
||||
// Profile fields are missing AND `measurementCount < 5` so the
|
||||
// `shouldShowChecklist` gate is true (its `stillInSetup` branch is
|
||||
// satisfied by the measurement-count condition). With the fix, the
|
||||
// card mounts collapsed: header + progress meter visible, full row
|
||||
// list hidden until the user clicks the chevron.
|
||||
//
|
||||
// v1.4.22 C4 — the onboarding redirect now lives in `proxy.ts`,
|
||||
// not in `<AuthShell>`. The proxy reads the `hl_onboarding` cookie
|
||||
// (which the auth routes mirror from the DB). Our storageState
|
||||
// belongs to a fully-onboarded user (no `hl_onboarding` cookie),
|
||||
// so the proxy passes the dashboard through even when the mocked
|
||||
// `/api/auth/me` reports `onboardingCompletedAt: null`. That's the
|
||||
// exact case this test wants to cover — the checklist mounts
|
||||
// because `measurementCount < 5` AND `onboardingCompletedAt` is
|
||||
// null, but the page itself still paints.
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -176,10 +176,12 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
}
|
||||
const state = await ctx.storageState();
|
||||
// Strip any cookies we don't recognise so storageState only carries
|
||||
// what the auth shell needs. (Mostly defensive — the proxy currently
|
||||
// only sets `healthlog_session` and `healthlog_locale`.)
|
||||
// what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the
|
||||
// login route sets it to "pending" for new users and DELETES it for
|
||||
// already-onboarded ones, so for our pre-seeded completed e2e user
|
||||
// the cookie is absent and the proxy passes the dashboard through.
|
||||
state.cookies = state.cookies.filter((c) =>
|
||||
["healthlog_session", "healthlog_locale"].includes(c.name),
|
||||
["healthlog_session", "healthlog_locale", "hl_onboarding"].includes(c.name),
|
||||
);
|
||||
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
await ctx.dispose();
|
||||
|
||||
+25
-19
@@ -216,7 +216,7 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, willkommen zurück.",
|
||||
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"greetingSalutation": "Hallo, {name}",
|
||||
"streakLabel": "Tage in Folge",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
@@ -262,7 +262,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
"subtitle": "Gewicht, Blutdruck, Puls & mehr",
|
||||
"subtitle": "Gewicht, Blutdruck, Puls und mehr",
|
||||
"addMeasurement": "Hinzufügen",
|
||||
"editMeasurement": "Messwert bearbeiten",
|
||||
"type": "Typ",
|
||||
@@ -327,7 +327,7 @@
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
"subtitle": "Stimmungsverlauf & Einträge",
|
||||
"subtitle": "Stimmungsverlauf und Einträge",
|
||||
"addEntry": "Hinzufügen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"moodLevel": "Stimmung",
|
||||
@@ -594,6 +594,7 @@
|
||||
"movingAverage7d": "7-Tage-Trend",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"avgAllTimeShort": "Allzeit",
|
||||
"trend7dShort": "7-Tage-Trend",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Zielbereiche",
|
||||
@@ -743,7 +744,7 @@
|
||||
"confidenceMittel": "Mittel",
|
||||
"confidenceGering": "Gering",
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||
"title": "Insights",
|
||||
"subtitle": "KI-basierte Analyse deiner Gesundheitsdaten",
|
||||
"overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick",
|
||||
@@ -773,10 +774,10 @@
|
||||
"progressAria": "Gesundheitsscore-Fortschritt",
|
||||
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
|
||||
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
|
||||
"componentBp": "BD",
|
||||
"componentBp": "Blutdruck",
|
||||
"componentWeight": "Gewicht",
|
||||
"componentMood": "Mood",
|
||||
"componentCompliance": "Medis",
|
||||
"componentMood": "Stimmung",
|
||||
"componentCompliance": "Einnahmetreue",
|
||||
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
|
||||
"askCoach": "Coach fragen",
|
||||
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
|
||||
@@ -825,12 +826,12 @@
|
||||
"confidenceLow": "Geringe Konfidenz"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Muster",
|
||||
"title": "Zusammenhänge",
|
||||
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
|
||||
"disclaimer": "Muster sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
|
||||
"disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
|
||||
"experimentCta": "7-Tage-Experiment starten",
|
||||
"experimentTooltip": "Bald verfügbar",
|
||||
"emptyTitle": "Mehr Daten nötig, um dieses Muster zu zeigen",
|
||||
"emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen",
|
||||
"sourceChip": "basierend auf {n} gepaarten Messungen · {window}",
|
||||
"sourceWindowLast30": "letzte 30 Tage",
|
||||
"confidenceHigh": "Hohe Konfidenz",
|
||||
@@ -852,8 +853,6 @@
|
||||
"errorProvider": "Der Coach konnte gerade keinen KI-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
|
||||
"newChat": "Neuer Chat",
|
||||
"settings": "Coach-Einstellungen",
|
||||
"settingsTooltip": "Coach-Einstellungen kommen mit v1.4.21.",
|
||||
"send": "Senden",
|
||||
"thinking": "Denke nach…",
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
@@ -870,6 +869,7 @@
|
||||
"sourcesFresh": "Aktuell",
|
||||
"sourcesStale": "Veraltet",
|
||||
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
|
||||
"evidenceLabel": "Worauf bezieht sich das?",
|
||||
"metric": {
|
||||
"bp": "Blutdruck",
|
||||
"weight": "Gewicht",
|
||||
@@ -899,6 +899,7 @@
|
||||
"navMood": "Stimmung",
|
||||
"navMedication": "Medikamente",
|
||||
"navBmi": "BMI",
|
||||
"navAriaLabel": "Zu Abschnitt springen",
|
||||
"measurementsOverDays": "{count} Messungen über {days} Tage",
|
||||
"healthStatus": "Gesundheitsstatus",
|
||||
"noWeightData": "Keine Gewichtsdaten oder Größe nicht hinterlegt.",
|
||||
@@ -1069,10 +1070,10 @@
|
||||
"targetSys": "Systolisch (mmHg)",
|
||||
"targetDia": "Diastolisch (mmHg)",
|
||||
"targetWeight": "Zielgewicht (kg)",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte versuche es erneut oder lege es später unter „Medikamente\" an.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte versuche es erneut.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
|
||||
"v2": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
@@ -1304,7 +1305,7 @@
|
||||
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
|
||||
"passkeyAlreadyRegistered": "Dieses Gerät ist bereits als Passkey für dein Konto registriert.",
|
||||
"passkeySecurityBlocked": "Dein Browser hat die Passkey-Anfrage aus Sicherheitsgründen blockiert. Bitte auf einer vertrauenswürdigen HTTPS-URL erneut versuchen.",
|
||||
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte erneut versuchen.",
|
||||
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte versuche es erneut.",
|
||||
"passkeyUnknownError": "Passkey-Registrierung fehlgeschlagen: {message}",
|
||||
"deletePasskey": "Passkey löschen?",
|
||||
"deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.",
|
||||
@@ -1808,6 +1809,7 @@
|
||||
"bugReportNotConfigured": "Bugreport ist noch nicht vollständig konfiguriert.",
|
||||
"bugReportSave": "Bugreport speichern",
|
||||
"userManagement": "Benutzerverwaltung",
|
||||
"userName": "Name",
|
||||
"userEmail": "E-Mail",
|
||||
"userRole": "Rolle",
|
||||
"userPasskeys": "Passkeys",
|
||||
@@ -1883,7 +1885,7 @@
|
||||
"reminderMissedMinutesDescription": "Minuten nach Fenster-Ende bis die Einnahme als verpasst gilt.",
|
||||
"reminderThresholdsSave": "Schwellenwerte speichern",
|
||||
"notificationTest": "Benachrichtigungen testen",
|
||||
"notificationTestSend": "Test-Notification senden",
|
||||
"notificationTestSend": "Test senden",
|
||||
"notificationTestSuccess": "Test-Notification wurde gesendet",
|
||||
"notificationTestFailed": "Test-Notification konnte nicht gesendet werden",
|
||||
"reminderCheckRun": "Reminder-Check ausführen",
|
||||
@@ -2043,7 +2045,7 @@
|
||||
"app-logs": {
|
||||
"title": "App-Logs",
|
||||
"subtitle": "Letzte strukturierte Wide-Events dieses Prozesses anzeigen.",
|
||||
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern LOKI_ENDPOINT konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
|
||||
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern die Integration konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
|
||||
"filterTraceId": "Trace-ID",
|
||||
"filterAction": "Aktion enthält…",
|
||||
"filterLevel": "Level",
|
||||
@@ -2431,6 +2433,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"eventColumn": "Ereignis",
|
||||
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
|
||||
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
|
||||
"goToSettings": "Zu den Einstellungen",
|
||||
@@ -2556,6 +2559,9 @@
|
||||
"moodStability": "Stimmungsstabilität",
|
||||
"steps": "Schritte",
|
||||
"average30d": "30-Tage-Schnitt:",
|
||||
"deltaVsLastMonth": "{delta} {unit} vs. Vormonat",
|
||||
"deltaVsLastMonthFlat": "± 0 {unit} vs. Vormonat",
|
||||
"deltaUnavailable": "Zu wenige Werte für einen Vormonats-Vergleich",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
|
||||
+15
-9
@@ -262,7 +262,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
"subtitle": "Weight, blood pressure, pulse & more",
|
||||
"subtitle": "Weight, blood pressure, pulse and more",
|
||||
"addMeasurement": "Add",
|
||||
"editMeasurement": "Edit measurement",
|
||||
"type": "Type",
|
||||
@@ -327,7 +327,7 @@
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
"subtitle": "Mood tracker & entries",
|
||||
"subtitle": "Mood tracker and entries",
|
||||
"addEntry": "Add",
|
||||
"editEntry": "Edit entry",
|
||||
"moodLevel": "Mood",
|
||||
@@ -594,6 +594,7 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Target ranges",
|
||||
@@ -825,12 +826,12 @@
|
||||
"confidenceLow": "Low confidence"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Patterns",
|
||||
"title": "Relationships",
|
||||
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
|
||||
"disclaimer": "Patterns are observational, not causal — talk to your doctor if BP runs high.",
|
||||
"disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
|
||||
"experimentCta": "Try a 7-day experiment",
|
||||
"experimentTooltip": "Coming soon",
|
||||
"emptyTitle": "Need more data to see this pattern",
|
||||
"emptyTitle": "Need more data to see this relationship",
|
||||
"sourceChip": "based on {n} paired readings · {window}",
|
||||
"sourceWindowLast30": "last 30 days",
|
||||
"confidenceHigh": "High confidence",
|
||||
@@ -852,8 +853,6 @@
|
||||
"errorProvider": "The Coach could not reach an AI provider just now. Please try again in a moment.",
|
||||
"tagline": "Personal health coach, grounded in your data.",
|
||||
"newChat": "New chat",
|
||||
"settings": "Coach settings",
|
||||
"settingsTooltip": "Coach settings arrive in v1.4.21.",
|
||||
"send": "Send",
|
||||
"thinking": "Thinking…",
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
@@ -870,6 +869,7 @@
|
||||
"sourcesFresh": "Fresh",
|
||||
"sourcesStale": "Stale",
|
||||
"sourcesFooter": "The Coach reads only the data you've connected.",
|
||||
"evidenceLabel": "What I'm looking at",
|
||||
"metric": {
|
||||
"bp": "Blood pressure",
|
||||
"weight": "Weight",
|
||||
@@ -899,6 +899,7 @@
|
||||
"navMood": "Mood",
|
||||
"navMedication": "Medication",
|
||||
"navBmi": "BMI",
|
||||
"navAriaLabel": "Skip to section",
|
||||
"measurementsOverDays": "{count} measurements over {days} days",
|
||||
"healthStatus": "Health Status",
|
||||
"noWeightData": "No weight data or height not set.",
|
||||
@@ -1808,6 +1809,7 @@
|
||||
"bugReportNotConfigured": "Bug report is not fully configured yet.",
|
||||
"bugReportSave": "Save bug report settings",
|
||||
"userManagement": "User Management",
|
||||
"userName": "Name",
|
||||
"userEmail": "Email",
|
||||
"userRole": "Role",
|
||||
"userPasskeys": "Passkeys",
|
||||
@@ -1883,7 +1885,7 @@
|
||||
"reminderMissedMinutesDescription": "Minutes after window end until the intake is considered missed.",
|
||||
"reminderThresholdsSave": "Save thresholds",
|
||||
"notificationTest": "Test Notifications",
|
||||
"notificationTestSend": "Send test notification",
|
||||
"notificationTestSend": "Send test",
|
||||
"notificationTestSuccess": "Test notification sent",
|
||||
"notificationTestFailed": "Failed to send test notification",
|
||||
"reminderCheckRun": "Run reminder check",
|
||||
@@ -2043,7 +2045,7 @@
|
||||
"app-logs": {
|
||||
"title": "App Logs",
|
||||
"subtitle": "Tail the most recent structured wide-events emitted by this process.",
|
||||
"processNote": "Showing app-process logs only — worker-process logs go to Loki when LOKI_ENDPOINT is configured. Buffer is volatile and capped at 500 entries.",
|
||||
"processNote": "Showing app-process logs only — worker-process logs go to Loki when that integration is configured. Buffer is volatile and capped at 500 entries.",
|
||||
"filterTraceId": "Trace ID",
|
||||
"filterAction": "Action contains…",
|
||||
"filterLevel": "Level",
|
||||
@@ -2431,6 +2433,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Event",
|
||||
"subtitle": "Choose which notifications you want to receive through which channel.",
|
||||
"noChannels": "Configure a notification channel in Settings first.",
|
||||
"goToSettings": "Go to Settings",
|
||||
@@ -2556,6 +2559,9 @@
|
||||
"moodStability": "Mood stability",
|
||||
"steps": "steps",
|
||||
"average30d": "30-day average:",
|
||||
"deltaVsLastMonth": "{delta} {unit} vs. last month",
|
||||
"deltaVsLastMonthFlat": "± 0 {unit} vs. last month",
|
||||
"deltaUnavailable": "Not enough data for a vs.-last-month comparison",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||
"sourceLabel": "Source: {source}",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.21",
|
||||
"version": "1.4.22",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.4.22 C4 — onboarding redirect moved from `<AuthShell>` useEffect
|
||||
* to `src/proxy.ts` so it lands on the first server response instead
|
||||
* of post-hydration. The previous client-side approach caused a brief
|
||||
* dashboard flash for users with `onboardingCompletedAt === null`.
|
||||
*
|
||||
* The proxy reads the `hl_onboarding` cookie (which the auth routes
|
||||
* mirror from the DB `onboardingCompletedAt` column) without a DB
|
||||
* roundtrip — Edge runtime can't reach Prisma. The cookie is a UX
|
||||
* hint, not a security signal: a user editing it locally only skips
|
||||
* the dashboard flash, they still can't bypass any server check.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
cookies: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
it("307s a session with hl_onboarding=pending from / to /onboarding", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/", {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
// NextResponse.redirect returns a 307 by default in Next 16.
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
|
||||
});
|
||||
|
||||
it("307s a pending session from /insights to /onboarding", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/insights", {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
|
||||
});
|
||||
|
||||
it("does NOT redirect from /onboarding itself", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/onboarding", {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
// /onboarding is in PUBLIC_PATHS so the proxy passes through; the
|
||||
// response is a NextResponse.next() with security headers applied.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT redirect from /auth/login", () => {
|
||||
const res = proxy(makeRequest("/auth/login", { hl_onboarding: "pending" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
|
||||
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through API routes even when onboarding is pending", () => {
|
||||
// API routes do their own getSession() checks — the proxy must
|
||||
// never redirect them or every legitimate /api/* call would 307.
|
||||
const res = proxy(
|
||||
makeRequest("/api/measurements", {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("still redirects unauthenticated users to /auth/login (existing contract)", () => {
|
||||
const res = proxy(makeRequest("/"));
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (Sec-MED-2) — exact-match `/onboarding`
|
||||
* plus subroutes. A hypothetical `/onboarding-export` route must
|
||||
* NOT inherit the public-pass-through that the literal
|
||||
* `/onboarding` carries.
|
||||
*/
|
||||
it("does NOT treat /onboarding-export as the onboarding surface (exact-match guard)", () => {
|
||||
const res = proxy(makeRequest("/onboarding-export"));
|
||||
// Unauthenticated request to a non-public route: redirects to
|
||||
// /auth/login. If the loose `startsWith("/onboarding")` matcher
|
||||
// were still in place it would short-circuit through public-path
|
||||
// handling and 200 instead.
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
});
|
||||
|
||||
it("still treats subroutes /onboarding/<x> as the onboarding surface", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/onboarding/step-2", {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
// /onboarding/step-2 is part of the onboarding surface so the
|
||||
// pending redirect short-circuits.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
||||
*
|
||||
* 1. The dashboard page does NOT mount `<CompareToggle />`. The
|
||||
* comparison-baseline switch belongs on `/insights` only — the
|
||||
* dashboard tile-strip is too tight for it (Marc 2026-05-10).
|
||||
* dashboard tile-strip is too tight for it (maintainer probe 2026-05-10).
|
||||
* The underlying `compareBaseline` value is still consumed by
|
||||
* every dashboard chart; only the on-surface affordance is gone.
|
||||
* 2. The `/insights` page renders exactly ONE page-level refresh
|
||||
@@ -22,7 +22,7 @@ import { join } from "node:path";
|
||||
* 4. The `/insights` page does NOT pass the `aiOverviewTitle`
|
||||
* ("Persönlicher Berater") subtitle into `<InsightAdvisorCard>`.
|
||||
* The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing;
|
||||
* the second subtitle was an empty-section placeholder Marc
|
||||
* the second subtitle was an empty-section placeholder the maintainer
|
||||
* called "Titel da aber NICHTS PASSIERT".
|
||||
* 5. The `stripChartTokens()` matcher catches lowercase + mixed-
|
||||
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`),
|
||||
@@ -150,7 +150,7 @@ describe("v1.4.19 A3 — /insights polish", () => {
|
||||
});
|
||||
|
||||
describe("v1.4.19 A3 — chart-token leak hardening", () => {
|
||||
// The visible "metric: blood_pressure_sweet" leak Marc saw at the
|
||||
// The visible "metric: blood_pressure_sweet" leak the maintainer saw at the
|
||||
// bottom of /insights was a model-emitted lowercase chart token
|
||||
// that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch
|
||||
// because the character class only matches uppercase. The widened
|
||||
@@ -180,3 +180,65 @@ describe("v1.4.19 A3 — chart-token leak hardening", () => {
|
||||
expect(parseChartTokens("metric:WEIGHT")).toEqual(["metric:WEIGHT"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta uses period-aligned baseline", () => {
|
||||
/**
|
||||
* Up to v1.4.22 W4 the BD tile computed `bp30 - bpAll`
|
||||
* (last-30-days minus all-time) regardless of which window the user
|
||||
* picked under Settings → Dashboard. The caption still rendered "vs.
|
||||
* last month" / "vs. last year" via `comparison.captionLastMonth` /
|
||||
* `comparison.captionLastYear`, so the user read a sentence whose
|
||||
* numerator was actually a 30d-vs-all-time delta. Every other tile
|
||||
* routes through `tileCompareDelta()` with `summary.avg30LastMonth`
|
||||
* / `summary.avg30LastYear` for honest period-aligned math.
|
||||
*
|
||||
* This test pins that the dashboard now subtracts the prior-period
|
||||
* window (priorMonth / priorYear) instead of all-time.
|
||||
*/
|
||||
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
|
||||
const src = load(DASHBOARD_PATH);
|
||||
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
|
||||
expect(src).toContain("bpInTargetPctPriorMonth");
|
||||
expect(src).toContain("bpInTargetPctPriorYear");
|
||||
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
|
||||
// for the comparison delta any more.
|
||||
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
|
||||
});
|
||||
|
||||
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
|
||||
const ROUTE_SRC = readFileSync(
|
||||
join(ROOT, "src/app/api/analytics/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth");
|
||||
expect(ROUTE_SRC).toContain("bpInTargetPctPriorYear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
|
||||
/**
|
||||
* The comparison-overlay toggle is a global preference. Up to v1.4.21
|
||||
* the toggle existed both in `/settings/dashboard` (canonical) and
|
||||
* `/insights` (via `<DailyBriefing metaSlot={<CompareToggle />} />`).
|
||||
* Per `feedback_settings_no_split.md` the toggle now lives in
|
||||
* Settings only — the on-surface affordance is gone from `/insights`.
|
||||
* Every chart still consumes the resolved `comparisonBaseline` value
|
||||
* the same way it did before, so flipping the Settings toggle still
|
||||
* propagates to the page on next refetch.
|
||||
*/
|
||||
it("/insights does NOT mount <CompareToggle /> anywhere", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).not.toMatch(/<CompareToggle\b/);
|
||||
expect(src).not.toMatch(
|
||||
/from\s+["']@\/components\/comparison\/compare-toggle["']/,
|
||||
);
|
||||
});
|
||||
|
||||
it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => {
|
||||
// Drift guard — the page must still hand `compareBaseline` to every
|
||||
// chart so the global Settings toggle keeps driving the overlay.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toContain("comparisonBaseline");
|
||||
expect(src).toContain("compareBaseline");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.19 phase A7 — Marc reported the `/targets` (Zielwerte) page
|
||||
* v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page
|
||||
* still surfaces English status labels even when the rest of the UI
|
||||
* renders in German: "Low / On Target / Stable / Moderate" stayed in
|
||||
* the source language. The card titles ("Weight", "Blood pressure",
|
||||
@@ -131,7 +131,7 @@ describe("/targets page — i18n status labels and titles", () => {
|
||||
|
||||
it("translates every status badge category to German", () => {
|
||||
const html = render("de");
|
||||
// Marc explicitly named these in his bug report.
|
||||
// the maintainer explicitly named these in his bug report.
|
||||
expect(html).not.toMatch(/>Low</);
|
||||
expect(html).not.toMatch(/>On target</);
|
||||
expect(html).not.toMatch(/>Stable</);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
/**
|
||||
* v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between
|
||||
* the page header (h1 + intro) and the first card grid. At Pixel-5
|
||||
* Marc reported "relativ viel Platz" wasted between the overview
|
||||
* the maintainer reported "relativ viel Platz" wasted between the overview
|
||||
* and the first values. Tighten the rhythm to `space-y-6` (24px)
|
||||
* which matches the rest of the admin / settings pages.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.22 C1 — `/targets` (Zielwerte) page upgrade.
|
||||
*
|
||||
* Each target card grew a 30-day sparkline + a "Δ vs. last month"
|
||||
* caption so the page reads as a living health log instead of a
|
||||
* static reference card. The API ships `points30d: number[]` and
|
||||
* `deltaVsLastMonth: number` per target; the page renders a tiny
|
||||
* dependency-free SVG path plus the localised delta caption.
|
||||
*
|
||||
* Cards without enough data (fewer than 3 points in either window)
|
||||
* keep their previous v1.4.19 layout — sparkline and delta caption
|
||||
* stay absent so cold-start accounts don't see a misleading flat
|
||||
* trace or a "± 0" comparison that's really "no comparison".
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: 88.2,
|
||||
average30: 88.6,
|
||||
trend: "stable",
|
||||
unit: "kg",
|
||||
range: { min: 60, max: 80 },
|
||||
classification: { category: "Normal", color: "#50fa7b" },
|
||||
source: "WHO BMI",
|
||||
points30d: [89.0, 88.8, 88.6, 88.4, 88.2],
|
||||
deltaVsLastMonth: -2.3,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: 72,
|
||||
average30: 72,
|
||||
trend: "stable",
|
||||
unit: "bpm",
|
||||
range: { min: 60, max: 100 },
|
||||
classification: { category: "On target", color: "#50fa7b" },
|
||||
source: "AHA",
|
||||
// No sparkline yet — cold-start account with <3 readings.
|
||||
points30d: null,
|
||||
deltaVsLastMonth: null,
|
||||
},
|
||||
],
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "tester",
|
||||
email: "tester@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — sparkline + delta", () => {
|
||||
it("renders a sparkline SVG for cards with points30d", () => {
|
||||
const html = render();
|
||||
// The sparkline carries a stable data-slot the visual test pins.
|
||||
expect(html).toContain('data-slot="target-sparkline"');
|
||||
// Path with the dracula-purple token, not a hardcoded hex.
|
||||
expect(html).toContain("var(--dracula-purple)");
|
||||
});
|
||||
|
||||
it("renders the Δ-vs-last-month caption with the signed delta", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="target-delta"');
|
||||
// Weight: −2.3 kg vs. last month
|
||||
expect(html).toMatch(/−2\.3\s*kg/);
|
||||
expect(html).toContain("vs. last month");
|
||||
});
|
||||
|
||||
it("uses German vs.-Vormonat caption when the locale is `de`", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("vs. Vormonat");
|
||||
});
|
||||
|
||||
it("skips both sparkline and delta when the target has no points", () => {
|
||||
const html = render();
|
||||
// PULSE card has no points30d / no deltaVsLastMonth — only ONE
|
||||
// `target-sparkline` slot should be present (from the WEIGHT card).
|
||||
const sparklineMatches = html.match(/data-slot="target-sparkline"/g) ?? [];
|
||||
expect(sparklineMatches.length).toBe(1);
|
||||
const deltaMatches = html.match(/data-slot="target-delta"/g) ?? [];
|
||||
expect(deltaMatches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
|
||||
// doesn't replace our JSON body with its HTML error page — that
|
||||
// rewrite was the root cause of the "Unexpected token '<'…" client
|
||||
// crash Marc hit when typing a bad OpenAI key.
|
||||
// crash the maintainer hit when typing a bad OpenAI key.
|
||||
expect(response.status).toBe(422);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
@@ -139,7 +139,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
});
|
||||
|
||||
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
||||
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
|
||||
// ran against the SAVED provider (the maintainer reported "wirft was Komisches raus").
|
||||
// The route now accepts a JSON override body and forwards the user's
|
||||
// unsaved selection to resolveProviderForTest().
|
||||
describe("POST /api/ai/test — dropdown-aware override", () => {
|
||||
|
||||
@@ -95,7 +95,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
|
||||
// the body for its own HTML error page — `await res.json()` in the
|
||||
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
|
||||
// which is exactly how Marc encountered this when typing a bad
|
||||
// which is exactly how the maintainer encountered this when typing a bad
|
||||
// OpenAI key. 4xx codes pass through untouched.
|
||||
const status = err.httpStatus ?? 0;
|
||||
let outboundStatus: number;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||
import { berlinDayKey } from "@/lib/analytics/berlin-day";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import {
|
||||
computeHealthScore,
|
||||
@@ -69,19 +70,30 @@ export const GET = apiHandler(async () => {
|
||||
let bpInTargetPct: number | null = null;
|
||||
let bpInTargetPct7d: number | null = null;
|
||||
let bpInTargetPct30d: number | null = null;
|
||||
let bpInTargetPctAllTime: number | null = null;
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
|
||||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||
* stops mismatching its math with its label. The tile's
|
||||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||
* matching the user's `comparisonBaseline` selection, never
|
||||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// v1.4.19 A1 — fetch ALL paired BP rows, not just the trailing 30
|
||||
// days. Up to v1.4.18 we filtered to the last 30 days at the DB
|
||||
// level and the headline (`bpInTargetPct`) was routed through
|
||||
// `windows.last30Days?.pct` — making the headline a literal copy
|
||||
// of the `30T` sub-value. On a busy account (e.g. 572 paired
|
||||
// readings with recent 30d = 50 %, all-time ≈ 11 %) the tile
|
||||
// pinned 50/50/50 and looked algorithmically broken. The windowed
|
||||
// helper now also returns an independent `allTime` aggregate,
|
||||
// which we surface as the headline so the three numbers can
|
||||
// diverge naturally.
|
||||
// v1.4.22 A1 — re-anchor the BD-Zielbereich tile headline to the
|
||||
// last-30-day window. Up to v1.4.19 the headline pinned to the
|
||||
// 30-day average (so 7d / 30d / total all read 50 %). v1.4.19 A1
|
||||
// flipped the headline to all-time, which made the tile correct
|
||||
// but emotionally wrong: the headline was the slowest-moving
|
||||
// aggregate possible, punishing recent improvement. v1.4.22 A1
|
||||
// re-routes the headline to last-30-days and surfaces 7d / 30d /
|
||||
// all-time as a 3-line sub-row so power users still see the
|
||||
// long-arc number without it dominating. The helper still returns
|
||||
// every window — only the headline pick changed.
|
||||
const [sysData, diaData] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
||||
@@ -94,9 +106,12 @@ export const GET = apiHandler(async () => {
|
||||
]);
|
||||
|
||||
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
||||
bpInTargetPct = windows.allTime?.pct ?? null;
|
||||
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
@@ -150,6 +165,9 @@ export const GET = apiHandler(async () => {
|
||||
bpInTargetPct,
|
||||
bpInTargetPct7d,
|
||||
bpInTargetPct30d,
|
||||
bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear,
|
||||
glucoseByContext,
|
||||
correlations,
|
||||
healthScore,
|
||||
@@ -316,6 +334,11 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
return { bpCompliance, moodPulse, weightWeekday };
|
||||
}
|
||||
|
||||
// v1.4.22 W5 reconcile (Code-MED-3) — `berlinDayKey()` lifted to
|
||||
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
|
||||
// bucketing shares the same Europe/Berlin contract. The
|
||||
// `weekday: "short"` formatter still lives here because it's only
|
||||
// used by `berlinIsoWeekday()` below.
|
||||
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
@@ -324,14 +347,6 @@ const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
function berlinDayKey(d: Date): string {
|
||||
const parts = BERLIN_DATE_PARTS.formatToParts(d);
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const day = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function dateFromBerlinKey(key: string): Date {
|
||||
// Anchor to UTC midnight — the date is a sortable bucket label rather
|
||||
// than a wall-clock timestamp, so DST drift is irrelevant.
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/auth/password", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -76,7 +76,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy can short-circuit the redirect before the
|
||||
// page hydrates.
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -2,12 +2,20 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { getGravatarUrl } from "@/lib/gravatar";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy
|
||||
// sessions that predate the cookie. New sessions anchor the cookie
|
||||
// inside `createSession` itself, so this is no longer the primary
|
||||
// write path; it just makes sure pre-v1.4.22 sessions get their
|
||||
// cookie set on the first /me roundtrip after the upgrade.
|
||||
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
|
||||
|
||||
annotate({ action: { name: "auth.me" } });
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/auth/passkey", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -65,8 +65,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` anchors the
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy short-circuits the redirect before
|
||||
// hydration.
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
|
||||
await auditLog("auth.login.passkey", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -75,10 +75,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
// Invalidate all existing sessions and create a fresh one
|
||||
// Invalidate all existing sessions and create a fresh one.
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
|
||||
// `hl_onboarding` cookie itself.
|
||||
await destroyAllSessions(user.id);
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt == null,
|
||||
getClientIp(request),
|
||||
request.headers.get("user-agent"),
|
||||
);
|
||||
|
||||
@@ -94,9 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Create session immediately
|
||||
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
|
||||
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
|
||||
// users are always pending so the proxy redirects on first
|
||||
// navigation instead of waiting for hydration.
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
await createSession(user.id, true, ip, ua);
|
||||
|
||||
await auditLog("auth.register", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* PUT /api/dashboard/chart-overlay-prefs — v1.4.18
|
||||
*
|
||||
* Persist a single chart's overlay-prefs (3 toggles) onto the existing
|
||||
* `User.dashboardWidgetsJson` blob. Marc rejected v1.4.16's always-on
|
||||
* `User.dashboardWidgetsJson` blob. the maintainer rejected v1.4.16's always-on
|
||||
* chart overlays (gradient fill, personal-baseline reference line,
|
||||
* target-zone shading); the new pattern is per-chart switches that the
|
||||
* user opts into independently and that persist per user.
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { NextRequest } from "next/server";
|
||||
// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
|
||||
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
|
||||
// one here makes the PUT 422 silently — the toast surfaces "Layout
|
||||
// konnte nicht gespeichert werden" — and Marc's tile-toggle looks
|
||||
// konnte nicht gespeichert werden" — and the user's tile-toggle looks
|
||||
// like it does nothing because the save round-trip never completes.
|
||||
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so
|
||||
// every save attempted with the achievements widget present (i.e.
|
||||
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
/**
|
||||
* Hard cap on total turns kept inside the per-call prompt window.
|
||||
@@ -260,18 +262,61 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const replyText = (result.content ?? "").trim();
|
||||
if (!replyText) {
|
||||
const rawReply = (result.content ?? "").trim();
|
||||
if (!rawReply) {
|
||||
return streamProviderError({ code: "coach.provider.empty" });
|
||||
}
|
||||
|
||||
// v1.4.22 — strip the optional `---KEYVALUES---` … `---END---`
|
||||
// sentinel out of the prose. The stripped prose is what we stream
|
||||
// to the client and persist; the parsed entries enrich the
|
||||
// provenance envelope so the UI can render the collapsible
|
||||
// "Worauf bezieht sich das?" disclosure.
|
||||
const sentinel = parseKeyValuesSentinel(rawReply);
|
||||
const proseAfterStrip = sentinel.prose.trim();
|
||||
// v1.4.22 W5 reconcile (Code-H1) — when the model emits a
|
||||
// sentinel-only / malformed reply, `sentinel.prose` is empty after
|
||||
// stripping. The previous fallback `sentinel.prose.trim() || rawReply`
|
||||
// surfaced raw `---KEYVALUES---` markers to the user. The empty-prose
|
||||
// condition signals an unusable provider response: short-circuit to
|
||||
// the structured `coach.provider.empty` error frame instead of
|
||||
// streaming the raw sentinel body.
|
||||
if (!proseAfterStrip) {
|
||||
annotate({
|
||||
action: { name: "coach.keyvalues.parse_failed" },
|
||||
meta: {
|
||||
kept: sentinel.keyValues.length,
|
||||
reason: "empty_prose_after_strip",
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.empty" });
|
||||
}
|
||||
const replyText = proseAfterStrip;
|
||||
const enrichedProvenance =
|
||||
sentinel.keyValues.length > 0
|
||||
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
||||
: snapshot.provenance;
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
// sentinel format has drifted, but pass the prose through
|
||||
// unchanged.
|
||||
annotate({
|
||||
action: { name: "coach.keyvalues.parse_failed" },
|
||||
meta: {
|
||||
kept: sentinel.keyValues.length,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the assistant message BEFORE we begin streaming; if the
|
||||
// client disconnects we still have the canonical row.
|
||||
const assistantMessage = await appendMessage({
|
||||
conversationId: workingConversationId,
|
||||
role: "assistant",
|
||||
content: replyText,
|
||||
metricSource: snapshot.provenance,
|
||||
metricSource: enrichedProvenance,
|
||||
providerType: workingProviderType,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
});
|
||||
@@ -296,29 +341,23 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
});
|
||||
|
||||
// ── Stream the body to the client ────────────────────────────
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
try {
|
||||
for (const tok of tokeniseForStreaming(replyText)) {
|
||||
controller.enqueue(encodeFrame({ type: "token", token: tok }));
|
||||
}
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "provenance",
|
||||
metricSource: snapshot.provenance,
|
||||
}),
|
||||
);
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId: workingConversationId,
|
||||
messageId: assistantMessage.id,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
const stream = createSseStream((controller) => {
|
||||
for (const tok of tokeniseForStreaming(replyText)) {
|
||||
controller.enqueue(encodeFrame({ type: "token", token: tok }));
|
||||
}
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "provenance",
|
||||
metricSource: enrichedProvenance,
|
||||
}),
|
||||
);
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId: workingConversationId,
|
||||
messageId: assistantMessage.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
@@ -368,43 +407,29 @@ async function streamRefusal(args: {
|
||||
promptVersion: PROMPT_VERSION,
|
||||
});
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encodeFrame({ type: "token", token: args.refusalText }),
|
||||
);
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId,
|
||||
messageId: refusalMessage.id,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(encodeFrame({ type: "token", token: args.refusalText }));
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
conversationId,
|
||||
messageId: refusalMessage.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
function streamProviderError(args: { code: string }): Response {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "error",
|
||||
code: args.code,
|
||||
message: args.code,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "error",
|
||||
code: args.code,
|
||||
message: args.code,
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Status 200 so the streaming client reads the SSE body and parses
|
||||
// the structured `error` frame (HTTP-status branches drop the
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
|
||||
});
|
||||
|
||||
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
|
||||
// Marc reported the previous 2/h was too aggressive when iterating on
|
||||
// the maintainer reported the previous 2/h was too aggressive when iterating on
|
||||
// settings. The 10/h ceiling is the new default; the env override lets
|
||||
// operators on a tight LLM budget dial it back without a rebuild.
|
||||
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import { berlinDayKey } from "@/lib/analytics/berlin-day";
|
||||
import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
@@ -56,6 +57,19 @@ interface TargetItem {
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
/**
|
||||
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
|
||||
* is the last 30 days of values (chronological, oldest → newest) for
|
||||
* the target's primary metric, rounded to one decimal. Derived metrics
|
||||
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
|
||||
* field when the page already renders a richer status surface.
|
||||
* `deltaVsLastMonth` is the signed difference between the current
|
||||
* 30-day average and the prior 30-day average (current − prior),
|
||||
* null when either window has fewer than 3 points to keep the
|
||||
* comparison honest.
|
||||
*/
|
||||
points30d?: number[] | null;
|
||||
deltaVsLastMonth?: number | null;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -98,17 +112,28 @@ export const GET = apiHandler(async () => {
|
||||
];
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
|
||||
// can render a "Δ vs. last month" caption alongside its sparkline.
|
||||
// We fetch 60 days in one query to keep the route round-trip count
|
||||
// identical to the previous version.
|
||||
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
// Fetch all measurements in the last 60 days + the latest for each type
|
||||
const windowedMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
measuredAt: { gte: sixtyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
const recentMeasurements = windowedMeasurements.filter(
|
||||
(m) => m.measuredAt >= thirtyDaysAgo,
|
||||
);
|
||||
const priorMeasurements = windowedMeasurements.filter(
|
||||
(m) => m.measuredAt < thirtyDaysAgo,
|
||||
);
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
@@ -163,6 +188,50 @@ export const GET = apiHandler(async () => {
|
||||
return "stable";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
|
||||
* the last 30 days of values for a measurement type into a daily
|
||||
* mean series (chronological, oldest → newest). The delta is the
|
||||
* signed difference between the current 30-day window's average
|
||||
* and the prior 30-day window's average. Both helpers skip the
|
||||
* computation when there are fewer than 3 points in a window so
|
||||
* the comparison stays honest on cold-start accounts.
|
||||
*/
|
||||
function sparklinePoints(type: MeasurementType): number[] | null {
|
||||
const data = recentMeasurements
|
||||
.filter((m) => m.type === type)
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
if (data.length < 3) return null;
|
||||
|
||||
// v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar
|
||||
// day, not UTC. A 23:30-Berlin reading on Tuesday otherwise
|
||||
// lands in Wednesday's UTC bucket and the sparkline drifts a
|
||||
// day on cross-DST boundaries. Aligns with `berlinDayKey()`
|
||||
// already used by the dashboard analytics route.
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of data) {
|
||||
const day = berlinDayKey(m.measuredAt);
|
||||
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += m.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
return Array.from(byDay.values()).map(
|
||||
(b) => Math.round((b.sum / b.count) * 10) / 10,
|
||||
);
|
||||
}
|
||||
|
||||
function deltaVsLastMonth(type: MeasurementType): number | null {
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === type);
|
||||
const priorOfType = priorMeasurements.filter((m) => m.type === type);
|
||||
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
|
||||
const recentAvg =
|
||||
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
|
||||
const priorAvg =
|
||||
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
|
||||
return Math.round((recentAvg - priorAvg) * 10) / 10;
|
||||
}
|
||||
|
||||
// Build target items
|
||||
const targets: TargetItem[] = [];
|
||||
|
||||
@@ -185,6 +254,8 @@ export const GET = apiHandler(async () => {
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
points30d: sparklinePoints("WEIGHT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
|
||||
});
|
||||
|
||||
// 2. Blood Pressure (sys/dia combined)
|
||||
@@ -210,6 +281,8 @@ export const GET = apiHandler(async () => {
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
@@ -276,6 +349,8 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
points30d: sparklinePoints("PULSE"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
|
||||
});
|
||||
|
||||
// 4. Sleep Duration
|
||||
@@ -295,6 +370,8 @@ export const GET = apiHandler(async () => {
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
points30d: sparklinePoints("SLEEP_DURATION"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
|
||||
});
|
||||
|
||||
// 5. BMI (derived from weight + height)
|
||||
@@ -318,6 +395,19 @@ export const GET = apiHandler(async () => {
|
||||
bmiClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
|
||||
// BMI sparkline / delta derive from the weight series \u2014 divide each
|
||||
// point by height\u00B2 so the y-axis matches the BMI range bar above.
|
||||
const weightPoints = sparklinePoints("WEIGHT");
|
||||
const bmiPoints =
|
||||
weightPoints != null
|
||||
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
|
||||
: null;
|
||||
const weightDelta = deltaVsLastMonth("WEIGHT");
|
||||
const bmiDelta =
|
||||
weightDelta != null
|
||||
? Math.round((weightDelta / heightSq) * 10) / 10
|
||||
: null;
|
||||
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
@@ -328,6 +418,8 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: 18.5, max: 24.9 },
|
||||
classification: bmiClassification,
|
||||
source: "WHO",
|
||||
points30d: bmiPoints,
|
||||
deltaVsLastMonth: bmiDelta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,6 +445,8 @@ export const GET = apiHandler(async () => {
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
points30d: sparklinePoints("BODY_FAT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
|
||||
});
|
||||
|
||||
// 7. Activity Steps
|
||||
@@ -371,6 +465,8 @@ export const GET = apiHandler(async () => {
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
points30d: sparklinePoints("ACTIVITY_STEPS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
|
||||
@@ -22,7 +22,7 @@ import { prisma } from "@/lib/db";
|
||||
* comes back asynchronously when the container is healthy / fails to
|
||||
* start. Without this surface we lose visibility once the deploy is
|
||||
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
|
||||
* verification recipe (Marc had to SSH and check digests).
|
||||
* verification recipe (the maintainer had to SSH and check digests).
|
||||
*
|
||||
* Payload shape: Coolify sends a free-form JSON object whose exact
|
||||
* fields drift between beta releases. We treat it as `Record<string,
|
||||
@@ -104,7 +104,7 @@ function hasValidSecret(request: NextRequest): boolean {
|
||||
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
|
||||
* existing dispatcher to every user whose role is ADMIN. The dispatcher
|
||||
* silently no-ops when no Telegram channel is configured, so this is
|
||||
* safe even on a first-deploy where Marc hasn't wired the bot yet.
|
||||
* safe even on a first-deploy where the maintainer hasn't wired the bot yet.
|
||||
*/
|
||||
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
|
||||
const admins = await prisma.user.findMany({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -56,5 +57,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data,
|
||||
});
|
||||
|
||||
// v1.4.22 C4 — clear the proxy-readable onboarding cookie so the
|
||||
// next navigation drops the /onboarding redirect immediately.
|
||||
await setOnboardingPendingCookie(false);
|
||||
|
||||
return apiSuccess({ completed: true });
|
||||
});
|
||||
+1
-1
@@ -204,7 +204,7 @@
|
||||
* bar. Used by the mobile section strips in `<AdminShell>` and
|
||||
* `<SettingsShell>` so 13 admin / 10 settings entries can swipe
|
||||
* horizontally without painting an always-on scrollbar at the top of
|
||||
* every admin/settings page (a recurring Marc complaint — most
|
||||
* every admin/settings page (a recurring maintainer complaint — most
|
||||
* recently mistaken for a `/admin/api-tokens` table scrollbar in
|
||||
* v1.4.16/17 because the strip sits right above the api-tokens card).
|
||||
*
|
||||
|
||||
+104
-37
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -49,7 +50,6 @@ import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { toWeekISO } from "@/lib/insights/week-iso";
|
||||
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
||||
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
||||
// wrapper so the bundle only lands once a correlation card actually renders.
|
||||
// Every scatter card sits inside a `length >= 5` gate and below the fold,
|
||||
@@ -562,7 +562,7 @@ export default function InsightsPage() {
|
||||
// preference so a toggle flip in Settings → Dashboard updates both
|
||||
// surfaces atomically.
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -950,6 +950,15 @@ export default function InsightsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* v1.4.22 A5 — section tabs lift above the hero strip so the
|
||||
user sees the metric-tab nav before scrolling. The nav itself
|
||||
remains a sticky scroll-anchored strip — clicking a tab
|
||||
scrolls to the matching section, scrolling highlights the
|
||||
active tab. Hero + Daily Briefing always render below the
|
||||
nav; the metric tabs control which sub-sections are visible
|
||||
in the user's viewport. */}
|
||||
<InsightsSectionNav />
|
||||
|
||||
<HeroStrip
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
@@ -979,14 +988,13 @@ export default function InsightsPage() {
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
metaSlot={<CompareToggle />}
|
||||
/>
|
||||
|
||||
{/* v1.4.20 phase B3 — Correlation discovery row. Three pre-defined
|
||||
hypotheses (BP × compliance, mood × pulse, weight × weekday) gated
|
||||
on n >= 14 + p < 0.05; cards below the bar render a per-card
|
||||
empty-state. The row-level disclaimer ("Patterns are observational,
|
||||
not causal …") sits below the grid once. */}
|
||||
empty-state. The row-level disclaimer ("Relationships are
|
||||
observational, not causal …") sits below the grid once. */}
|
||||
{analytics?.correlations && (
|
||||
<CorrelationRow results={analytics.correlations} />
|
||||
)}
|
||||
@@ -1010,9 +1018,7 @@ export default function InsightsPage() {
|
||||
legacyPayload={advisor.payload?.legacyPayload ?? false}
|
||||
/>
|
||||
|
||||
<InsightsSectionNav />
|
||||
|
||||
<section id="section-general" className="scroll-mt-28 space-y-2">
|
||||
<section id="section-general" className="scroll-mt-16 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.generalStatusTitle")}
|
||||
@@ -1036,7 +1042,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 3: Blood pressure */}
|
||||
<section id="section-bp" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-bp" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.bloodPressureSectionTitle")}
|
||||
@@ -1062,7 +1068,15 @@ export default function InsightsPage() {
|
||||
annotations={bpStoryboardAnnotations}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
|
||||
100 % width. When the mood section is hidden the BP-medication
|
||||
card collapses to full-width instead of leaving the right
|
||||
half empty. */}
|
||||
<div
|
||||
className={
|
||||
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1242,7 +1256,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 4: Weight */}
|
||||
<section id="section-weight" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-weight" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.weightSectionTitle")}
|
||||
@@ -1264,7 +1278,13 @@ export default function InsightsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
|
||||
100 % width. Mirrors the BP-section grid normalisation. */}
|
||||
<div
|
||||
className={
|
||||
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1406,7 +1426,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 5: Pulse */}
|
||||
<section id="section-pulse" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-pulse" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.pulseSectionTitle")}
|
||||
@@ -1441,7 +1461,7 @@ export default function InsightsPage() {
|
||||
|
||||
{/* Section: Mood */}
|
||||
{showMoodSection && (
|
||||
<section id="section-mood" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-mood" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.moodSectionTitle")}
|
||||
@@ -1469,7 +1489,7 @@ export default function InsightsPage() {
|
||||
)}
|
||||
|
||||
{/* Section 6: Medication Compliance */}
|
||||
<section id="section-meds" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-meds" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.medicationCompliance")}
|
||||
@@ -1485,7 +1505,15 @@ export default function InsightsPage() {
|
||||
</div>
|
||||
|
||||
{data.medications.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
/* v1.4.22 A4 — row-fill rule: a single medication card fills
|
||||
100 % width instead of half the row. */
|
||||
<div
|
||||
className={
|
||||
data.medications.length >= 2
|
||||
? "grid gap-4 sm:grid-cols-2"
|
||||
: "grid gap-4"
|
||||
}
|
||||
>
|
||||
{data.medications.map((med) => {
|
||||
const medicationSummary = medicationSummaryById.get(med.id);
|
||||
return (
|
||||
@@ -1562,7 +1590,7 @@ export default function InsightsPage() {
|
||||
</section>
|
||||
|
||||
{/* Section 7: BMI */}
|
||||
<section id="section-bmi" className="scroll-mt-28 space-y-4">
|
||||
<section id="section-bmi" className="scroll-mt-16 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.bmiSectionTitle")}
|
||||
@@ -1702,10 +1730,19 @@ function InsightsSectionNav() {
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
|
||||
// highest intersectionRatio in the band rather than the
|
||||
// observer-supplied last entry. Three sections briefly
|
||||
// visible during a fast scroll otherwise made the active
|
||||
// pill jump to whichever one came last in the batch.
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
const top = visible[0];
|
||||
if (top) {
|
||||
setActiveId((current) =>
|
||||
current === top.target.id ? current : top.target.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-30% 0px -60% 0px" },
|
||||
@@ -1721,26 +1758,56 @@ function InsightsSectionNav() {
|
||||
|
||||
function scrollTo(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
if (!el) return;
|
||||
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
|
||||
// `prefers-reduced-motion`; honour the user's OS-level pref.
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-background/80 sticky top-0 z-30 -mx-4 overflow-x-auto border-b px-4 py-2 backdrop-blur-sm md:-mx-6 md:px-6">
|
||||
// v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility +
|
||||
// sticky-strip polish. Notable changes:
|
||||
// - aria-label so screen-reader landmark traversal hears the
|
||||
// rail's purpose ("Skip to section" / "Zu Abschnitt springen").
|
||||
// - bg-background/95 instead of /80 to kill the hero-glow bleed
|
||||
// during scroll.
|
||||
// - Drop the `-mx-4 / md:-mx-6` negative margin; the parent
|
||||
// container handles horizontal padding so 280px (Galaxy Fold)
|
||||
// doesn't get a ghost scrollbar.
|
||||
// - Hide the inner overflow's scrollbar so the sticky strip
|
||||
// reads as a single bar.
|
||||
<nav
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
className={cn(
|
||||
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
{SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
activeId === id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
))}
|
||||
{SECTION_IDS.map((id) => {
|
||||
const isActive = activeId === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function NotificationsPage() {
|
||||
<thead>
|
||||
<tr className="border-border border-b">
|
||||
<th className="text-muted-foreground px-4 py-3 text-left text-xs font-medium tracking-wider uppercase">
|
||||
{t("notifications.title")}
|
||||
{t("notifications.eventColumn")}
|
||||
</th>
|
||||
{activeChannels.map((ch) => (
|
||||
<th
|
||||
|
||||
+87
-11
@@ -97,6 +97,21 @@ interface AnalyticsData {
|
||||
*/
|
||||
bpInTargetPct7d?: number | null;
|
||||
bpInTargetPct30d?: number | null;
|
||||
/**
|
||||
* v1.4.22 A1 — long-arc all-time aggregate. After the headline
|
||||
* re-anchor to last-30-days the all-time number lives as a sub-value
|
||||
* on the BD-Zielbereich tile (alongside `7d` and `30d`).
|
||||
*/
|
||||
bpInTargetPctAllTime?: number | null;
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
|
||||
* pcts. The BD-Zielbereich tile's comparison-overlay caption picks
|
||||
* `priorMonth` for `comparisonBaseline === "lastMonth"` and
|
||||
* `priorYear` for `lastYear` so the rendered "Δ X% vs. last month"
|
||||
* stays honest. Null when the prior window has no paired readings.
|
||||
*/
|
||||
bpInTargetPctPriorMonth?: number | null;
|
||||
bpInTargetPctPriorYear?: number | null;
|
||||
glucoseByContext?: Record<string, DataSummaryType>;
|
||||
}
|
||||
|
||||
@@ -193,7 +208,7 @@ export default function DashboardPage() {
|
||||
});
|
||||
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -315,7 +330,7 @@ export default function DashboardPage() {
|
||||
// v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself
|
||||
// self-handles the empty state (CTA → /achievements), so we only need
|
||||
// the layout-toggle gate here. No data-floor check (the empty card is
|
||||
// intentional — Marc wants the user to discover the feature).
|
||||
// intentional — the maintainer wants the user to discover the feature).
|
||||
const showAchievementsCard = isChartVisible("achievements");
|
||||
// v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard
|
||||
// insights preview by the layout toggle. The component itself
|
||||
@@ -585,7 +600,7 @@ export default function DashboardPage() {
|
||||
// BP is conceptually one signal but visually two tiles (sys + dia)
|
||||
// so the user sees both numbers side by side at the same size as
|
||||
// every other tile in the strip. v1.4.3 first attempted a
|
||||
// combined tile with `secondary` values — Marc preferred two
|
||||
// combined tile with `secondary` values — the maintainer preferred two
|
||||
// distinct tiles with consistent symmetric widths.
|
||||
trendCards.push({
|
||||
id: "bp-sys",
|
||||
@@ -794,6 +809,63 @@ export default function DashboardPage() {
|
||||
});
|
||||
}
|
||||
if (showBpInTargetTile) {
|
||||
/* v1.4.22 A2 — feature parity with every other tile.
|
||||
Synthesise a slope from the difference between the 7-day
|
||||
and 30-day in-target shares: when the recent week is
|
||||
above the recent month, the metric is improving (up-good
|
||||
⇒ green arrow); when below, it's slipping. The
|
||||
trend7Delta is the same number as the arrow's underlying
|
||||
signal, surfaced as "(+5)" next to `7d:` so the tile
|
||||
matches the (weight / BP / pulse) call-site contract.
|
||||
Comparison overlay routes through the same global
|
||||
`compareBaseline` / `tileCompareDelta` pipeline as every
|
||||
other tile; we only have a single % series for the BP
|
||||
tile (no DataSummary) so the prior-period delta uses
|
||||
`bpInTargetPctAllTime` as the long-arc baseline — when
|
||||
comparison is off the field stays null. */
|
||||
const bp7 = data?.bpInTargetPct7d ?? null;
|
||||
const bp30 = data?.bpInTargetPct30d ?? null;
|
||||
const bpAll = data?.bpInTargetPctAllTime ?? null;
|
||||
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
|
||||
const bpPriorYear = data?.bpInTargetPctPriorYear ?? null;
|
||||
const bpTrendDelta =
|
||||
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
|
||||
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
|
||||
bpTrendDelta === null
|
||||
? null
|
||||
: {
|
||||
slope: bpTrendDelta / 30,
|
||||
direction:
|
||||
bpTrendDelta > 0.5
|
||||
? "up"
|
||||
: bpTrendDelta < -0.5
|
||||
? "down"
|
||||
: "stable",
|
||||
// Synthetic slope (last-7d minus last-30d) carries no
|
||||
// R² — pin to 1 so existing TrendSlope consumers don't
|
||||
// mis-interpret a `0` as "very low confidence".
|
||||
confidence: 1,
|
||||
};
|
||||
// v1.4.22 W5 reconcile (Code-H2) — match the comparison
|
||||
// window the user picked. `lastMonth` baseline ⇒ compare
|
||||
// last30 against the now-60d…now-30d window (priorMonth);
|
||||
// `lastYear` baseline ⇒ compare against the matching window
|
||||
// shifted back 365 days. The previous shortcut subtracted
|
||||
// `bpAll` regardless of baseline, which produced a number
|
||||
// whose magnitude was honest but whose label ("vs. last
|
||||
// month") lied to the user.
|
||||
const bpComparePrior =
|
||||
compareBaseline === "lastMonth"
|
||||
? bpPriorMonth
|
||||
: compareBaseline === "lastYear"
|
||||
? bpPriorYear
|
||||
: null;
|
||||
const bpCompareDelta =
|
||||
compareBaseline === "none" ||
|
||||
bp30 === null ||
|
||||
bpComparePrior === null
|
||||
? null
|
||||
: Math.round((bp30 - bpComparePrior) * 10) / 10;
|
||||
trendCards.push({
|
||||
id: "bpInTarget",
|
||||
order: widgetOrder("bpInTarget"),
|
||||
@@ -807,11 +879,15 @@ export default function DashboardPage() {
|
||||
windowed analytics fields. Up to v1.4.17 these were
|
||||
hard-coded to null and rendered "—" even when the
|
||||
user had paired BP readings in both windows. */
|
||||
avg7={data?.bpInTargetPct7d ?? null}
|
||||
avg30={data?.bpInTargetPct30d ?? null}
|
||||
slope30={null}
|
||||
avg7={bp7}
|
||||
avg30={bp30}
|
||||
avgAllTime={bpAll}
|
||||
slope30={bpSlope30}
|
||||
trend7Delta={bpTrendDelta}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={bpCompareDelta}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1034,7 +1110,7 @@ export default function DashboardPage() {
|
||||
// v1.4.15 phase-B4 — slotted at the user's chosen position via
|
||||
// the layout `order`. Default order from
|
||||
// `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row)
|
||||
// which matches Marc's brief "below the chart row".
|
||||
// which matches the maintainer's brief "below the chart row".
|
||||
charts.push({
|
||||
id: "achievements",
|
||||
order: widgetOrder("achievements"),
|
||||
@@ -1076,7 +1152,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
{/* v1.4: dashboard tiles are *always* a single row.
|
||||
* Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row
|
||||
* maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row
|
||||
* tile strip breaks the visual hierarchy and reads like an
|
||||
* Excel grid. Total width caps at the parent container —
|
||||
* exactly the chart-width below. When the active tile count
|
||||
@@ -1091,9 +1167,9 @@ export default function DashboardPage() {
|
||||
{/* v1.4.16 Fix A5: hide the strip entirely when the user
|
||||
turned off every tile. Until v1.4.15 the wrapper rendered
|
||||
an empty grid even with zero tiles — visually a thin gap
|
||||
Marc described as "awkward". Charts below still render so
|
||||
the maintainer described as "awkward". Charts below still render so
|
||||
the page is not empty; the tile-strip just goes away.
|
||||
The constraint Marc named — "immer die gesamte Spalte
|
||||
The constraint the maintainer named — "immer die gesamte Spalte
|
||||
breit und immer der gleichen Höhe" — is preserved by the
|
||||
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
|
||||
continues to give every visible tile equal width / equal
|
||||
@@ -1107,7 +1183,7 @@ export default function DashboardPage() {
|
||||
// x-coordinates as the charts below because both inherit the
|
||||
// same parent container. When the row no longer fits a 9rem
|
||||
// floor, the grid wraps to a new row instead of horizontal-
|
||||
// scrolling — Marc tested both and prefers the v1.3-era
|
||||
// scrolling — the maintainer tested both and prefers the v1.3-era
|
||||
// wrap behaviour over the one-row scroll for the symmetry
|
||||
// it preserves.
|
||||
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
|
||||
|
||||
@@ -38,6 +38,13 @@ interface TargetData {
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
/**
|
||||
* v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API
|
||||
* skips both when a window has fewer than 3 readings so the comparison
|
||||
* stays honest on a fresh account.
|
||||
*/
|
||||
points30d?: number[] | null;
|
||||
deltaVsLastMonth?: number | null;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -110,7 +117,7 @@ const GLUCOSE_TYPES = new Set([
|
||||
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
|
||||
* A7 wires this map so the German locale stops surfacing English
|
||||
* status pills like "Low / On Target / Stable / Moderate" — the
|
||||
* exact regression Marc reported.
|
||||
* exact regression the maintainer reported.
|
||||
*/
|
||||
const STATUS_CATEGORY_KEY: Record<string, string> = {
|
||||
// BMI
|
||||
@@ -241,6 +248,60 @@ function getTargetSourceLink(target: TargetData): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.22 C1 — tiny inline sparkline. Each target card paints the last
|
||||
* 30 days of values beneath the range bar so the page reads as a
|
||||
* "current state + recent journey" surface rather than a static
|
||||
* reference card. The SVG is dependency-free and renders in <1ms; we
|
||||
* deliberately avoid pulling Recharts in here because every card on
|
||||
* the page would mount its own ResponsiveContainer.
|
||||
*
|
||||
* The path stays in the dracula-purple token so the colour reads as a
|
||||
* neutral trace, not a status signal — the range bar above already
|
||||
* carries the green/yellow/red semantics and a second status colour
|
||||
* on the sparkline would compete for the user's eye.
|
||||
*/
|
||||
function Sparkline({
|
||||
points,
|
||||
className,
|
||||
}: {
|
||||
points: number[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (points.length < 2) return null;
|
||||
const min = Math.min(...points);
|
||||
const max = Math.max(...points);
|
||||
const span = Math.max(max - min, 0.0001);
|
||||
const w = 100;
|
||||
const h = 24;
|
||||
const stepX = w / (points.length - 1);
|
||||
const path = points
|
||||
.map((value, i) => {
|
||||
const x = i * stepX;
|
||||
const y = h - ((value - min) / span) * h;
|
||||
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
className={`h-6 w-full ${className ?? ""}`}
|
||||
aria-hidden="true"
|
||||
data-slot="target-sparkline"
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="var(--dracula-purple)"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
|
||||
if (trend === "up") {
|
||||
return <TrendingUp className="h-4 w-4 text-orange-400" />;
|
||||
@@ -507,6 +568,31 @@ function TargetCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The
|
||||
sparkline collapses to a thinner trace on mobile via the
|
||||
shared 24px height, and the delta caption sits on its own
|
||||
line so it stays scannable next to the range bar above. */}
|
||||
{target.points30d && target.points30d.length >= 2 && (
|
||||
<div className="space-y-1" data-slot="target-trend">
|
||||
<Sparkline points={target.points30d} />
|
||||
{target.deltaVsLastMonth != null && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="target-delta"
|
||||
>
|
||||
{Math.abs(target.deltaVsLastMonth) < 0.05
|
||||
? t("targets.deltaVsLastMonthFlat", { unit: target.unit })
|
||||
: t("targets.deltaVsLastMonth", {
|
||||
delta:
|
||||
(target.deltaVsLastMonth > 0 ? "+" : "−") +
|
||||
Math.abs(target.deltaVsLastMonth).toFixed(1),
|
||||
unit: target.unit,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMedicationCompliance && medicationBreakdown.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -649,7 +735,7 @@ export default function TargetsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
// v1.4.19 phase A7 — Marc reported "relativ viel Platz" wasted
|
||||
// v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
|
||||
// between the overview header and the first values on `/targets`.
|
||||
// Tighten the rhythm from `space-y-8` (32 px) to `space-y-6`
|
||||
// (24 px) — that matches the admin / settings pages.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
* is always wider than a 393 CSS-px (Pixel-5) viewport, so v1.4.18
|
||||
* phase A2 added the `no-scrollbar` class to the strip — Chromium /
|
||||
* WebKit otherwise paint an always-on horizontal scrollbar at the top
|
||||
* of every admin page, which Marc kept mis-attributing to the
|
||||
* of every admin page, which the maintainer kept mis-attributing to the
|
||||
* `/admin/api-tokens` table card right below it. Three previous fix
|
||||
* attempts (v1.4.15 column-hide, v1.4.16 mobile-card-list, then this
|
||||
* one) all targeted the table while the bar lived in the shell.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
* v1.4 shared-admin page where 13 sections lived on one route; in
|
||||
* v1.5 every section has its own dedicated route, so collapsing the
|
||||
* only card on `/admin/api-tokens` hides the entire surface and
|
||||
* confused Marc more than it helped.
|
||||
* confused the maintainer more than it helped.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("ApiTokenOverviewSection — responsive", () => {
|
||||
it("mobile card-list has no overflow-x-auto wrapper", () => {
|
||||
const html = render();
|
||||
// Mobile cards must let content wrap within the card rather than
|
||||
// forcing a scroll container — that was the v1.4.15 bug Marc
|
||||
// forcing a scroll container — that was the v1.4.15 bug the maintainer
|
||||
// re-reported. Confirm there's no overflow-x-auto INSIDE the
|
||||
// mobile <ul>.
|
||||
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
|
||||
@@ -122,6 +122,32 @@ describe("ApiTokenOverviewSection — responsive", () => {
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
|
||||
});
|
||||
|
||||
it("desktop date cells drop whitespace-nowrap (v1.4.22 C2 5th-attempt fix)", () => {
|
||||
const html = render();
|
||||
// The "Last used" + "Created" `<td>`s no longer carry
|
||||
// `whitespace-nowrap`. The v1.4.19 production probe confirmed that
|
||||
// class was the actual culprit behind the residual horizontal
|
||||
// scrollbar at 768-1280px: a formatted date+time string
|
||||
// ("05.05.2026, 21:46") overflowed its 12% colgroup allotment
|
||||
// (~84px on a 700px content area) and won over `table-fixed`'s
|
||||
// width contract, so the table's intrinsic width exceeded 100%
|
||||
// and the wrapping `overflow-x-auto` painted a scrollbar. Letting
|
||||
// the cell wrap to two lines costs one row of height but kills
|
||||
// the bar.
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/);
|
||||
expect(tableMatch).not.toBeNull();
|
||||
const tableMarkup = tableMatch![0];
|
||||
// Pick every <td> inside the desktop table that renders one of the
|
||||
// two date columns (text-right + text-xs is unique to those cells)
|
||||
// and assert none carries whitespace-nowrap.
|
||||
const dateCellMatches =
|
||||
tableMarkup.match(/<td[^>]*\btext-right\b[^>]*\btext-xs\b[^>]*>/g) ?? [];
|
||||
expect(dateCellMatches.length).toBeGreaterThanOrEqual(2);
|
||||
for (const cell of dateCellMatches) {
|
||||
expect(cell).not.toContain("whitespace-nowrap");
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile timestamp lines wrap (no nowrap on the meta paragraphs)", () => {
|
||||
const html = render();
|
||||
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
|
||||
|
||||
@@ -4,13 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
/**
|
||||
* v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix.
|
||||
*
|
||||
* Marc reported a "minimaler Scrollbar" still painting at the
|
||||
* the maintainer reported a "minimaler Scrollbar" still painting at the
|
||||
* bottom-right of the api-tokens page, even after the v1.4.18 phase
|
||||
* A2 mobile-strip `no-scrollbar` fix. The remaining culprits are
|
||||
* inside the section card itself: long token names, long usernames,
|
||||
* and long permission strings can each push their parent past the
|
||||
* viewport. Cumulatively this leaves the page in an "almost-fits"
|
||||
* state where the visible scrollbar Marc sees is the page-level main
|
||||
* state where the visible scrollbar the maintainer sees is the page-level main
|
||||
* scroll picking up a fraction of pixels of horizontal scroll spill
|
||||
* from the children.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase A2: the `/admin` overview no longer renders the
|
||||
* `StatusCardGrid` (the section-card grid Marc described as redundant
|
||||
* `StatusCardGrid` (the section-card grid the maintainer described as redundant
|
||||
* with the sidebar nav). The status-card component itself was removed
|
||||
* from the codebase. This test guards the *new* overview composition:
|
||||
* a welcome card, the system snapshot, and the recent-audit preview.
|
||||
|
||||
@@ -153,7 +153,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
painted scrollbar at the top of every admin page; the
|
||||
horizontal swipe + keyboard arrow scrolling still work, the
|
||||
bar just doesn't draw. Without this the 13-section strip
|
||||
would render an always-on scrollbar that Marc kept
|
||||
would render an always-on scrollbar that the maintainer kept
|
||||
mis-attributing to the api-tokens table below it. */}
|
||||
<nav
|
||||
aria-label={t("admin.shell.sectionsNav")}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { type ApiTokenInfo } from "./_shared";
|
||||
*
|
||||
* v1.4.19 phase A7 (4th attempt) — earlier fixes (column-hide,
|
||||
* mobile card-list, mobile-strip `no-scrollbar`) cleared every
|
||||
* scrollbar Marc reported except a residual painted bar he kept
|
||||
* scrollbar the maintainer reported except a residual painted bar he kept
|
||||
* seeing at the bottom-right. Production probe confirmed no
|
||||
* remaining horizontal-overflow culprits, but the cards still
|
||||
* carried `break-all` next to `truncate` (dead code; `white-space:
|
||||
@@ -55,6 +55,46 @@ function TruncatedCell({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active
|
||||
* 3-way ternary is identical between the desktop table and the
|
||||
* mobile card list; the only difference is `text-xs` vs `text-[10px]`.
|
||||
* Centralise so the two surfaces stay in lock-step.
|
||||
*/
|
||||
function TokenStatusBadge({
|
||||
token,
|
||||
size,
|
||||
}: {
|
||||
token: ApiTokenInfo;
|
||||
size: "sm" | "xs";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const expired =
|
||||
token.expiresAt != null && new Date(token.expiresAt) < new Date();
|
||||
const sizeClass = size === "xs" ? "text-[10px]" : "text-xs";
|
||||
if (token.revoked) {
|
||||
return (
|
||||
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (expired) {
|
||||
return (
|
||||
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
className={`bg-dracula-green/15 text-dracula-green shrink-0 ${sizeClass}`}
|
||||
>
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F-18 (v1.4.19): the auto-login flow names tokens
|
||||
* "web auto-login 2026-05-05T19:46:20.603Z" / "iOS auto-login …" by
|
||||
@@ -63,8 +103,13 @@ function TruncatedCell({
|
||||
* "iOS auto-login · 05.05.2026 19:46" while leaving any non-ISO suffix
|
||||
* (manual names, device fingerprints, etc.) untouched.
|
||||
*/
|
||||
// v1.4.22 D / D-CR-M-03 — broaden the ISO match to include non-Z
|
||||
// offsets (e.g. `+02:00`, `-05:30`). The previous regex only matched
|
||||
// UTC (`Z`), so a token name carrying a local-tz timestamp slipped
|
||||
// through unformatted and rendered the raw `2026-05-05T19:46:20.603+02:00`
|
||||
// suffix instead of the locale-aware `05.05.2026 21:46` chunk.
|
||||
const TOKEN_NAME_ISO_RE =
|
||||
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(.*)$/;
|
||||
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/;
|
||||
|
||||
// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the
|
||||
// rest of the app shows timestamps in Berlin time (`format-locale.ts`),
|
||||
@@ -99,7 +144,7 @@ export function ApiTokenOverviewSection() {
|
||||
// section route. The previous pattern carried a "Collapse / Expand"
|
||||
// toggle as an escape hatch from the v1.4 shared admin page where
|
||||
// 13 sections lived together, but on a route that only renders this
|
||||
// one card the toggle hides the entire surface. Marc reported it
|
||||
// one card the toggle hides the entire surface. the maintainer reported it
|
||||
// as "sinnlos" — gone.
|
||||
const { data: tokens, isLoading } = useQuery({
|
||||
queryKey: ["admin", "tokens"],
|
||||
@@ -176,8 +221,6 @@ export function ApiTokenOverviewSection() {
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{tokens.map((token, i) => {
|
||||
const isExpired =
|
||||
token.expiresAt && new Date(token.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={token.id}
|
||||
@@ -208,26 +251,27 @@ export function ApiTokenOverviewSection() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{token.revoked ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
) : isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
<TokenStatusBadge token={token} size="sm" />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{/* v1.4.22 C2 (5th attempt) — drop
|
||||
`whitespace-nowrap` on the date cells. The
|
||||
v1.4.19 A7 probe confirmed `formatDateTime`
|
||||
renders "05.05.2026, 21:46" (~110px) which
|
||||
exceeds the 12% `<col>` allotment (~84px on
|
||||
a 700px content area); `whitespace-nowrap`
|
||||
wins over `table-fixed`'s width contract,
|
||||
so the table's intrinsic width exceeds 100%
|
||||
and the wrapper's `overflow-x-auto` paints
|
||||
the scrollbar the maintainer kept reporting
|
||||
for the 5th time. Letting the date+time
|
||||
wrap to two lines on narrow viewports costs
|
||||
one row of height but eliminates the bar. */}
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
|
||||
{token.lastUsedAt
|
||||
? formatDateTime(token.lastUsedAt)
|
||||
: t("admin.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
|
||||
{formatDate(token.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -246,8 +290,6 @@ export function ApiTokenOverviewSection() {
|
||||
data-testid="admin-tokens-mobile-list"
|
||||
>
|
||||
{tokens.map((token) => {
|
||||
const isExpired =
|
||||
token.expiresAt && new Date(token.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={token.id}
|
||||
@@ -260,25 +302,7 @@ export function ApiTokenOverviewSection() {
|
||||
value={formatTokenName(token.name)}
|
||||
className="min-w-0 flex-1 font-medium"
|
||||
/>
|
||||
{token.revoked ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
) : isExpired ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green shrink-0 text-[10px]">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
<TokenStatusBadge token={token} size="xs" />
|
||||
</div>
|
||||
<TruncatedCell
|
||||
value={token.user.username}
|
||||
|
||||
@@ -115,7 +115,10 @@ export function RemindersSection() {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<Bell className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@ export function UserManagementSection() {
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
{t("admin.userName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
/**
|
||||
* v1.4.18 — per-chart overlay-controls popover.
|
||||
*
|
||||
* Marc rejected the always-on overlays from B1a (gradient, baseline,
|
||||
* the maintainer rejected the always-on overlays from B1a (gradient, baseline,
|
||||
* target-zone shading) and asked for a per-chart switch surface so a
|
||||
* user can toggle "7-Tage-Trend / Trend-Pfeil / Zielbereich" on or off
|
||||
* for each chart independently. The popover is anchored top-right of
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
/**
|
||||
* v1.4.18 — Default overlay state on chart wrappers.
|
||||
*
|
||||
* Marc rejected the always-on personal-baseline reference line that B1a
|
||||
* the maintainer rejected the always-on personal-baseline reference line that B1a
|
||||
* painted on every chart. The default surface for a new chart should be
|
||||
* a clean line + axes + tooltip — no baseline, no target-zone shading,
|
||||
* no trend line. Overlays are user-opt-in via the per-chart settings
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("computePersonalBaseline()", () => {
|
||||
|
||||
describe("<HealthChart> v1.4.18 clean-line revert", () => {
|
||||
it("does NOT paint a gradient fill under the line (clean line only)", async () => {
|
||||
// v1.4.18 reverts B1a's gradient-area treatment. Marc explicitly
|
||||
// v1.4.18 reverts B1a's gradient-area treatment. the maintainer explicitly
|
||||
// rejected the soft-color fill below the line: the line itself is
|
||||
// the chart, no painted background under it. We assert the SSR
|
||||
// output ships no `<linearGradient>` defs and no `chart-gradient`
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
/**
|
||||
* v1.4.18 — MoodChart clean-line revert (gradient + emoji rolled back).
|
||||
*
|
||||
* Marc rejected B1a's coloured area fill below the chart line and the
|
||||
* the maintainer rejected B1a's coloured area fill below the chart line and the
|
||||
* smiley/emoji glyphs at every data point. The line stroke + Dracula
|
||||
* tokens stay; the chart now paints a clean monotone line with simple
|
||||
* Recharts dots.
|
||||
@@ -67,7 +67,7 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
|
||||
});
|
||||
|
||||
it("does NOT render emoji glyphs at data points (source check)", async () => {
|
||||
// Marc explicitly rejected smileys, especially in the mood chart.
|
||||
// the maintainer explicitly rejected smileys, especially in the mood chart.
|
||||
// Recharts does not paint <Line> dots during SSR (the dot callback
|
||||
// only fires after first client render), so the emoji wouldn't
|
||||
// surface in renderToStaticMarkup output anyway. Inspecting the
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
||||
* chart in a `<ResponsiveContainer>` whose default
|
||||
* `touch-action: auto` makes the browser wait for the chart's JS
|
||||
* touchmove handler before starting page scroll. On slow devices that
|
||||
* causes Marc's "Scrolling-Hänger im Dashboard auf Charts".
|
||||
* causes the maintainer's "Scrolling-Hänger im Dashboard auf Charts".
|
||||
*
|
||||
* Fix: every chart's wrapper `<div>` (the one that contains the
|
||||
* `<ResponsiveContainer>`) must carry the Tailwind `touch-pan-y`
|
||||
|
||||
@@ -9,7 +9,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
* v1.4.15 Fix 4 — "7-Tage-Schnitt" → "7-Tage-Trend".
|
||||
*
|
||||
* The avg7 line on every dashboard tile used to read "7d: 80.5" /
|
||||
* "7T: 80,5" — visually an *average*. Marc wanted it to convey
|
||||
* "7T: 80,5" — visually an *average*. the maintainer wanted it to convey
|
||||
* *direction* over the past week instead, so the label flips to
|
||||
* "7d trend" / "7T-Trend" and a signed delta indicator paints next
|
||||
* to the value with metric-aware colour:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Target } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.22 A2 — BD-Zielbereich tile feature parity.
|
||||
*
|
||||
* The BP-in-target tile now ships the same set of features every other
|
||||
* tile already has:
|
||||
* - the optional `avgAllTime` sub-value renders next to `7d` and
|
||||
* `30d` so the long-arc number stays visible after v1.4.22 A1
|
||||
* re-anchored the headline to the last-30-day window.
|
||||
* - the trend arrow + 7-day-trend chip use a synthesised slope from
|
||||
* the difference between the 7-day and 30-day in-target shares.
|
||||
* - the comparison overlay flows through the same `compareBaseline`
|
||||
* / `compareDelta` props the dashboard wires for every other tile.
|
||||
*
|
||||
* Other tiles leave `avgAllTime` undefined so the third sub-row never
|
||||
* renders — pinned via the negative case at the bottom.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
label: "BP in target",
|
||||
latest: 50,
|
||||
unit: "%",
|
||||
avg7: 55,
|
||||
avg30: 50,
|
||||
slope30: null,
|
||||
icon: Target,
|
||||
directionSentiment: "up-good" as const,
|
||||
};
|
||||
|
||||
describe("<TrendCard> avgAllTime sub-value (v1.4.22 A2)", () => {
|
||||
it("renders the all-time label + value when `avgAllTime` is provided", () => {
|
||||
const html = render(<TrendCard {...baseProps} avgAllTime={11} />);
|
||||
// The slot is keyed via `data-slot` so callers can target it
|
||||
// without scraping translated copy.
|
||||
expect(html).toContain('data-slot="trend-card-all-time"');
|
||||
expect(html).toContain("All-time");
|
||||
// The headline (50 %) is the 30-day pct after v1.4.22 A1; the
|
||||
// all-time number lives in the third sub-row. Both must render
|
||||
// (the formatter emits a trailing `.0` for integer values via the
|
||||
// shared `useFormatters().number(_, 1)` contract — same shape as
|
||||
// every other tile uses).
|
||||
expect(html).toContain(">50.0<");
|
||||
expect(html).toContain(">11.0<");
|
||||
});
|
||||
|
||||
it("paints the all-time value with the supplied color class", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
avgAllTime={11}
|
||||
avgAllTimeColorClass="text-dracula-orange"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
});
|
||||
|
||||
it("renders a dash for a null `avgAllTime` (no data yet) instead of a literal 'null'", () => {
|
||||
const html = render(<TrendCard {...baseProps} avgAllTime={null} />);
|
||||
expect(html).toContain('data-slot="trend-card-all-time"');
|
||||
expect(html).toContain(">—<");
|
||||
expect(html).not.toContain("null");
|
||||
});
|
||||
|
||||
it("does NOT render the all-time slot when `avgAllTime` is undefined (other tiles untouched)", () => {
|
||||
const html = render(<TrendCard {...baseProps} />);
|
||||
expect(html).not.toContain('data-slot="trend-card-all-time"');
|
||||
});
|
||||
|
||||
it("ships the trend arrow when a synthetic slope is provided", () => {
|
||||
// Synthetic slope: 7d (55) above 30d (50) → up direction → green
|
||||
// arrow under the up-good sentiment.
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
|
||||
avgAllTime={11}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
});
|
||||
|
||||
it("ships the 7-day-trend chip when a delta is provided", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
|
||||
trend7Delta={5}
|
||||
avgAllTime={11}
|
||||
/>,
|
||||
);
|
||||
// The TrendCard renders the delta as e.g. `(+5.0)` next to `7d:`.
|
||||
expect(html).toMatch(/\(\+5\.0\)/);
|
||||
});
|
||||
|
||||
it("ships the comparison overlay when comparison is active", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
avgAllTime={11}
|
||||
compareBaseline="lastMonth"
|
||||
compareDelta={4}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="tile-compare-delta"');
|
||||
expect(html).toContain("+4.0");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { LineChart as LineChartIcon } from "lucide-react";
|
||||
/**
|
||||
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
|
||||
*
|
||||
* Per Marc's research §1A — "personal-baseline framing first" — when a
|
||||
* Per the maintainer's research §1A — "personal-baseline framing first" — when a
|
||||
* user has only 1-2 data points there's nothing useful to plot, so we
|
||||
* paint a friendly hint instead of a near-empty canvas. The hint reuses
|
||||
* the `lucide-react/LineChart` glyph the rest of the app uses for chart
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
|
||||
/**
|
||||
* v1.4.18 — per-chart overlay-controls popover.
|
||||
*
|
||||
* Marc rolled back B1a's always-on chart overlays (gradient fills,
|
||||
* the maintainer rolled back B1a's always-on chart overlays (gradient fills,
|
||||
* personal-baseline reference line, target-zone shading) and asked for
|
||||
* a per-chart switch surface instead. Each dashboard chart card mounts
|
||||
* one of these in its top-right corner (a settings-cog dropdown) so the
|
||||
@@ -30,7 +30,7 @@ import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
|
||||
* - showTrendIndicator — the "7-day trend" overlay (moving average +
|
||||
* numeric Δ chip in the header)
|
||||
* - showTrendArrow — the trend regression line + arrow direction
|
||||
* hint + personal-baseline reference line. Marc's rule: the mean
|
||||
* hint + personal-baseline reference line. the maintainer's rule: the mean
|
||||
* line only paints when a trend is being displayed, so we fold
|
||||
* the baseline into this toggle.
|
||||
* - showTargetRange — the target-zone shading + reference lines
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -475,7 +475,7 @@ export function MoodChart({
|
||||
5: t("charts.moodLabel5"),
|
||||
};
|
||||
|
||||
// v1.4.18 — emoji glyph map removed. Marc explicitly rejected
|
||||
// v1.4.18 — emoji glyph map removed. the maintainer explicitly rejected
|
||||
// smileys in the mood chart; the line now uses plain Recharts dots
|
||||
// and the y-axis already labels each integer (very low / low / okay
|
||||
// / good / great) so the chart is fully scannable without a glyph.
|
||||
@@ -632,7 +632,7 @@ export function MoodChart({
|
||||
/>
|
||||
))}
|
||||
{/* v1.4.18 — personal-baseline gated behind the
|
||||
Trend toggle (Marc: "only when a trend is being
|
||||
Trend toggle (maintainer: "only when a trend is being
|
||||
displayed"). Default OFF. */}
|
||||
{showTrend && personalBaseline != null && (
|
||||
<ReferenceLine
|
||||
|
||||
@@ -87,6 +87,17 @@ interface TrendCardProps {
|
||||
*/
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
compareDelta?: number | null;
|
||||
/**
|
||||
* v1.4.22 A2 — optional third sub-value rendered alongside `7d` and
|
||||
* `30d`. Used by the BD-Zielbereich tile so the all-time aggregate
|
||||
* stays visible after v1.4.22 A1 re-anchored the headline to the
|
||||
* 30-day window. Pass the i18n key for the label (defaults to
|
||||
* `charts.avgAllTimeShort`); other tiles leave both fields undefined
|
||||
* so the third sub-row never renders.
|
||||
*/
|
||||
avgAllTime?: number | null;
|
||||
avgAllTimeLabelKey?: string;
|
||||
avgAllTimeColorClass?: string;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -106,6 +117,9 @@ export function TrendCard({
|
||||
trend7Delta = null,
|
||||
compareBaseline = "none",
|
||||
compareDelta = null,
|
||||
avgAllTime,
|
||||
avgAllTimeLabelKey = "charts.avgAllTimeShort",
|
||||
avgAllTimeColorClass,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -178,7 +192,7 @@ export function TrendCard({
|
||||
};
|
||||
|
||||
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
|
||||
// when the call site supplies a delta. Marc's v1.4.15 feedback —
|
||||
// when the call site supplies a delta. the maintainer's v1.4.15 feedback —
|
||||
// "7-Tage-Schnitt" sounds like an average, but the value next to it
|
||||
// is now a TREND. Distinct keys keep the label change in i18n.
|
||||
const avg7LabelKey =
|
||||
@@ -216,9 +230,20 @@ export function TrendCard({
|
||||
line below the latest value so the tile stays scannable on
|
||||
mobile viewports (no horizontal-scroll, no overlap with the
|
||||
existing 7d/30d row). Suppressed when comparison is off OR
|
||||
we don't have enough data to compute a delta. */}
|
||||
we don't have enough data to compute a delta.
|
||||
|
||||
v1.4.22 W5 reconcile (Design-H2) — on `<sm` viewports the
|
||||
BP-in-target tile (the only consumer of `avgAllTime`) packs
|
||||
three sub-rows + this callout into the height budget of
|
||||
every other tile. Hide the standalone callout at `<sm` for
|
||||
BP-in-target tiles; the secondary row below combines
|
||||
all-time + comparison-delta into a single line that stays
|
||||
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
|
||||
full layout returns at `>=sm`. */}
|
||||
{compareBaseline !== "none" && compareDelta != null && (
|
||||
<div className="mt-1">
|
||||
<div
|
||||
className={cn("mt-1", avgAllTime !== undefined && "hidden sm:block")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
@@ -293,7 +318,65 @@ export function TrendCard({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* v1.4.22 A2 — optional third sub-value (e.g. all-time aggregate
|
||||
for BD-Zielbereich after v1.4.22 A1 re-anchored the headline
|
||||
to last-30 days). Other tiles leave `avgAllTime` undefined so
|
||||
the third sub-row never renders.
|
||||
|
||||
v1.4.22 W5 reconcile (Design-H2) — full label only at
|
||||
`>=sm`; on `<sm` the all-time number moves to the
|
||||
combined secondary row below so the tile keeps the same
|
||||
vertical density as every other trend card. */}
|
||||
{avgAllTime !== undefined && (
|
||||
<span data-slot="trend-card-all-time" className="hidden sm:inline">
|
||||
{t(avgAllTimeLabelKey)}:{" "}
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
|
||||
>
|
||||
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* v1.4.22 W5 reconcile (Design-H2) — combined secondary row
|
||||
for `<sm` viewports. Renders the all-time aggregate AND
|
||||
(when comparison is on) the comparison delta on a single
|
||||
line: "All-time 11% · vs −0.4 last month". Disappears at
|
||||
`>=sm` because the full layout above takes over. */}
|
||||
{avgAllTime !== undefined && (
|
||||
<div
|
||||
data-slot="trend-card-secondary-mobile"
|
||||
className="text-muted-foreground mt-1 text-xs sm:hidden"
|
||||
>
|
||||
<span>
|
||||
{t(avgAllTimeLabelKey)}:{" "}
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
|
||||
>
|
||||
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
|
||||
</span>
|
||||
</span>
|
||||
{compareBaseline !== "none" && compareDelta != null && (
|
||||
<>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
comparisonDeltaColor,
|
||||
)}
|
||||
data-slot="tile-compare-delta-mobile"
|
||||
data-compare-baseline={compareBaseline}
|
||||
>
|
||||
{`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("<CorrelationCard>", () => {
|
||||
|
||||
it("renders the empty-state when status=insufficient", () => {
|
||||
const html = render(<CorrelationCard result={insufficient} />);
|
||||
expect(html).toContain("Need more data to see this pattern");
|
||||
expect(html).toContain("Need more data to see this relationship");
|
||||
expect(html).not.toContain("Try a 7-day experiment");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
|
||||
* v1.4.22 A4 update — empty-state cards are dropped instead of rendered.
|
||||
*
|
||||
* Acceptance:
|
||||
* 1. Renders the three hypothesis cards.
|
||||
* 2. Mounts the correlation-disclaimer footer once at the row level.
|
||||
* 3. 2-up grid on >= md, single column on < md.
|
||||
* 4. Below-threshold cards delegate their empty-state to
|
||||
* `<CorrelationCard>` (smoke-checked via the per-card slot).
|
||||
* 1. Renders only the hypothesis cards whose `status === "ok"`.
|
||||
* 2. Mounts the correlation-disclaimer footer once at the row level
|
||||
* when at least one card renders; the row hides itself entirely
|
||||
* when zero cards render (no header, no disclaimer).
|
||||
* 3. 2-up grid on >= md when 2+ cards, full-width single column when
|
||||
* exactly 1 card renders.
|
||||
* 4. Below-threshold cards are filtered at the row level so the
|
||||
* layout collapses cleanly.
|
||||
*/
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
@@ -67,7 +71,7 @@ const okWeightWeekday: CorrelationResult = {
|
||||
};
|
||||
|
||||
describe("<CorrelationRow>", () => {
|
||||
it("renders all three hypothesis cards", () => {
|
||||
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
@@ -78,9 +82,9 @@ describe("<CorrelationRow>", () => {
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
|
||||
expect(matches.length).toBe(3);
|
||||
expect(matches.length).toBe(2);
|
||||
expect(html).toMatch(/data-kind="bp-compliance"/);
|
||||
expect(html).toMatch(/data-kind="mood-pulse"/);
|
||||
expect(html).not.toMatch(/data-kind="mood-pulse"/);
|
||||
expect(html).toMatch(/data-kind="weight-weekday"/);
|
||||
});
|
||||
|
||||
@@ -96,10 +100,10 @@ describe("<CorrelationRow>", () => {
|
||||
);
|
||||
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(html).toContain("Patterns are observational, not causal");
|
||||
expect(html).toContain("Relationships are observational, not causal");
|
||||
});
|
||||
|
||||
it("applies the 2-up grid on >= md and single column on < md", () => {
|
||||
it("applies the 2-up grid on >= md when 2+ ok cards render", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
@@ -113,6 +117,36 @@ describe("<CorrelationRow>", () => {
|
||||
expect(html).toMatch(/md:grid-cols-2/);
|
||||
});
|
||||
|
||||
it("collapses to a full-width single column when exactly 1 ok card renders", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: insufficientMoodPulse,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
// The 2-up rule must NOT activate when only one card has data —
|
||||
// a single card should span 100 % width on its own row instead of
|
||||
// half-width with empty space next to it.
|
||||
expect(html).not.toMatch(/md:grid-cols-2/);
|
||||
});
|
||||
|
||||
it("hides the row entirely (no header, no disclaimer) when zero cards have data", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: insufficientMoodPulse,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: insufficientMoodPulse,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders German copy when locale=de", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
@@ -124,21 +158,6 @@ describe("<CorrelationRow>", () => {
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Muster");
|
||||
expect(html).toContain("nicht kausal");
|
||||
});
|
||||
|
||||
it("delegates empty-state to per-card empty-state when below threshold", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: insufficientMoodPulse,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: insufficientMoodPulse,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/Need more data to see this pattern/g) ?? [];
|
||||
expect(matches.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,7 @@ describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)
|
||||
});
|
||||
|
||||
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
|
||||
// Reproduces Marc's production /insights crash on 2026-05-10:
|
||||
// Reproduces the maintainer's production /insights crash on 2026-05-10:
|
||||
//
|
||||
// TypeError: Cannot read properties of undefined (reading 'replace')
|
||||
// at stripChartTokens(insight.summary)
|
||||
|
||||
@@ -243,4 +243,36 @@ describe("<RecommendationCard>", () => {
|
||||
);
|
||||
expect(html).not.toContain("data-feedback-thumb=");
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.22 A6 — strip chart-tokens from the always-visible
|
||||
* recommendation text. W1a §3 traced the production "metric:WEIGHT"
|
||||
* leak the maintainer saw at the bottom of the advisor card to this exact
|
||||
* render path: every other AI-prose surface ran through
|
||||
* `stripChartTokens()`, the recommendation text didn't. Pin the
|
||||
* strip so a future copy-paste regression can't reintroduce the
|
||||
* leak.
|
||||
*/
|
||||
it("strips literal chart tokens (metric:WEIGHT et al) from the rec text", () => {
|
||||
const recWithToken = {
|
||||
...recBase,
|
||||
text: "Increase fluids. metric:WEIGHT",
|
||||
};
|
||||
const html = render(<RecommendationCard rec={recWithToken} index={0} />);
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Increase fluids.");
|
||||
});
|
||||
|
||||
it("strips lowercase / mixed-case model output from the rec text", () => {
|
||||
const recWithLowercaseToken = {
|
||||
...recBase,
|
||||
text: "Watch the trend. metric:blood_pressure_sys today",
|
||||
};
|
||||
const html = render(
|
||||
<RecommendationCard rec={recWithLowercaseToken} index={0} />,
|
||||
);
|
||||
expect(html).not.toContain("metric:blood_pressure_sys");
|
||||
expect(html).toContain("Watch the trend.");
|
||||
expect(html).toContain("today");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<CoachInput>", () => {
|
||||
it("mounts the textarea, mic, send button, and disclaimer slots", () => {
|
||||
it("mounts the textarea, mic, and send button slots", () => {
|
||||
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
|
||||
// composer no longer renders its own paragraph below the input.
|
||||
const html = render(
|
||||
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
@@ -19,8 +21,8 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain('data-slot="coach-input-textarea"');
|
||||
expect(html).toContain('data-slot="coach-input-mic"');
|
||||
expect(html).toContain('data-slot="coach-input-send"');
|
||||
expect(html).toContain('data-slot="coach-input-disclaimer"');
|
||||
expect(html).toContain("Coach replies are generated");
|
||||
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
|
||||
expect(html).not.toContain("Coach replies are generated");
|
||||
});
|
||||
|
||||
it("renders the localised placeholder + hint", () => {
|
||||
@@ -31,13 +33,12 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain("Press Enter to send");
|
||||
});
|
||||
|
||||
it("renders the German placeholder + disclaimer when locale is 'de'", () => {
|
||||
it("renders the German placeholder under the 'de' locale", () => {
|
||||
const html = render(
|
||||
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Frag mich etwas zu deinen Daten");
|
||||
expect(html).toContain("Klinische Entscheidungen gehören in die Hand");
|
||||
});
|
||||
|
||||
it("disables the mic button with the v1.5 tooltip text", () => {
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The user bubble pulls `useAuth().user.gravatarUrl` to mirror the
|
||||
// Coach avatar (v1.4.22 B3). SSR rendering through `renderToStaticMarkup`
|
||||
// has no TanStack-Query provider, so we mock the hook the same way
|
||||
// `recommendation-feedback.test.tsx` does — returning a tester user
|
||||
// with no Gravatar so we exercise the initials fallback path by
|
||||
// default, and override per-test where needed.
|
||||
const useAuthMock = vi.fn<
|
||||
() => {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
gravatarUrl: string | null;
|
||||
};
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>(() => ({
|
||||
user: {
|
||||
id: "test-user",
|
||||
username: "tester",
|
||||
role: "USER",
|
||||
gravatarUrl: null,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
import { MessageThread } from "../message-thread";
|
||||
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
|
||||
|
||||
@@ -216,4 +248,139 @@ describe("<MessageThread>", () => {
|
||||
"Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
|
||||
// v1.4.22 — the Coach surfaces load-bearing numbers in a
|
||||
// collapsible "What I'm looking at" disclosure under the assistant
|
||||
// bubble. Verify the structure renders correctly and the entries
|
||||
// show label / value / unit / window.
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
keyValues: [
|
||||
{
|
||||
label: "avg7 systolic",
|
||||
value: "138",
|
||||
unit: "mmHg",
|
||||
window: "last7days",
|
||||
},
|
||||
{
|
||||
label: "avg30 systolic",
|
||||
value: "134",
|
||||
unit: "mmHg",
|
||||
window: "last30days",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />);
|
||||
expect(html).toContain('data-slot="coach-evidence"');
|
||||
expect(html).toContain("What I'm looking at");
|
||||
expect(html).toContain('data-slot="coach-evidence-list"');
|
||||
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(html).toContain("avg7 systolic");
|
||||
expect(html).toContain("138 mmHg");
|
||||
expect(html).toContain("(last7days)");
|
||||
expect(html).toContain("avg30 systolic");
|
||||
expect(html).toContain("134 mmHg");
|
||||
// Disclosure is collapsed by default (no `open` attribute).
|
||||
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
|
||||
});
|
||||
|
||||
it("renders the evidence-block label in German under 'de' locale", () => {
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
keyValues: [
|
||||
{
|
||||
label: "avg30 systolisch",
|
||||
value: "138",
|
||||
unit: "mmHg",
|
||||
window: "last30days",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />, "de");
|
||||
expect(html).toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
it("renders entries without unit or window cleanly", () => {
|
||||
const withKeyValues: CoachConversationDetailDTO = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
baseConversation.messages[0],
|
||||
{
|
||||
...baseConversation.messages[1],
|
||||
metricSource: {
|
||||
windows: [],
|
||||
metrics: ["compliance"],
|
||||
keyValues: [{ label: "30-day adherence", value: "96" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={withKeyValues} />);
|
||||
expect(html).toContain("30-day adherence");
|
||||
// No stray empty unit/window markup.
|
||||
expect(html).not.toMatch(
|
||||
/30-day adherence:\s*<\/span>\s*<strong[^>]*>\s*</,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the disclosure entirely when keyValues is empty or absent", () => {
|
||||
// baseConversation.messages[1].metricSource has no `keyValues`
|
||||
// field — the source chips still render but the disclosure does
|
||||
// not appear at all.
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).not.toContain('data-slot="coach-evidence"');
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
});
|
||||
|
||||
it("renders the user bubble with a Gravatar when one is set (B3 parity)", () => {
|
||||
// v1.4.22 B3 — the user-bubble avatar uses the same dimensions
|
||||
// as the Coach avatar and pulls the Gravatar URL from useAuth.
|
||||
useAuthMock.mockReturnValueOnce({
|
||||
user: {
|
||||
id: "u-1",
|
||||
username: "marc",
|
||||
role: "USER",
|
||||
gravatarUrl: "https://www.gravatar.com/avatar/abc123?s=64&d=mp",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
|
||||
expect(html).toMatch(
|
||||
/<img[^>]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-bubble-user-avatar"[^>]*class="[^"]*size-8/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to initials when no Gravatar URL is present", () => {
|
||||
// Default mock returns gravatarUrl: null → initials path.
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
|
||||
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
|
||||
});
|
||||
});
|
||||
@@ -44,11 +44,10 @@ describe("<SourcesRail>", () => {
|
||||
expect(html).toContain("Einnahmetreue");
|
||||
});
|
||||
|
||||
it("renders the footer disclaimer", () => {
|
||||
it("renders the medical disclaimer in the footer (relocated from the composer in v1.4.22)", () => {
|
||||
const html = render(<SourcesRail />);
|
||||
expect(html).toContain(
|
||||
"The Coach reads only the data you've connected.",
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-sources-disclaimer"');
|
||||
expect(html).toContain("Clinical decisions belong with your doctor");
|
||||
});
|
||||
|
||||
it("renders a checkbox per row + a window selector trigger", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Plus, Settings2, Sparkles } from "lucide-react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -11,12 +11,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -178,15 +172,13 @@ export function CoachDrawer({
|
||||
"flex h-[100dvh] flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{/* Header (full width). Title + new-chat button. Settings cog
|
||||
sits next to the avatar on the LEFT — the previous layout
|
||||
painted it next to the new-chat button on the right, where
|
||||
Radix Sheet's default close-X (absolute top-4 right-4)
|
||||
overlapped the cog and made it visually un-clickable. The
|
||||
new layout keeps the cog inside the visible header column
|
||||
and reserves the right edge for the close-X alone.
|
||||
v1.4.20.1: pr-12 ensures the new-chat button never slides
|
||||
under the close-X on narrower viewports either. */}
|
||||
{/* Header (full width). Avatar + title + new-chat button.
|
||||
v1.4.22 B5 — the settings cog was removed: it had no real
|
||||
wiring and the matching v1.4.21 placeholder tooltip
|
||||
("Coach settings arrive in v1.4.21") read as a dead button.
|
||||
A real settings surface for per-user prompt-tuning lands
|
||||
with v1.4.23. pr-12 keeps the new-chat button clear of the
|
||||
Sheet's close-X on narrower viewports. */}
|
||||
<SheetHeader
|
||||
data-slot="coach-drawer-header"
|
||||
className="border-border/70 flex-row items-center gap-3 border-b p-3 pr-12 sm:p-4 sm:pr-14"
|
||||
@@ -197,29 +189,6 @@ export function CoachDrawer({
|
||||
>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</div>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
// Match the visible disabled state — sighted users
|
||||
// see a coming-soon tooltip; SR users hear the same
|
||||
// copy instead of a working "Coach settings" label.
|
||||
aria-label={t("insights.coach.settingsTooltip")}
|
||||
data-slot="coach-drawer-settings"
|
||||
className="size-8 shrink-0"
|
||||
>
|
||||
<Settings2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("insights.coach.settingsTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="truncate text-sm font-semibold">
|
||||
{drawerTitle}
|
||||
|
||||
@@ -30,8 +30,9 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* with the iOS app in v1.5") because the natural shipper of voice is
|
||||
* the native client, not the PWA.
|
||||
*
|
||||
* The disclaimer below the input is a hard requirement — Coach
|
||||
* replies are not medical advice.
|
||||
* v1.4.22 B4: the disclaimer ("Coach replies are generated …") moved
|
||||
* out of the composer and into the sources rail footer, so the
|
||||
* composer stays focused on the input affordance.
|
||||
*/
|
||||
export interface CoachInputProps {
|
||||
value: string;
|
||||
@@ -55,37 +56,31 @@ export function CoachInput({
|
||||
}: CoachInputProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const canSubmit = !disabled && value.trim().length > 0;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
// Shift+Enter inserts a newline.
|
||||
if (event.shiftKey) return;
|
||||
// Plain Enter and ⌘/Ctrl+Enter both submit.
|
||||
// Shift+Enter inserts a newline; plain Enter and ⌘/Ctrl+Enter submit.
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
if (!disabled && value.trim().length > 0) {
|
||||
onSubmit();
|
||||
}
|
||||
if (canSubmit) onSubmit();
|
||||
},
|
||||
[disabled, onSubmit, value],
|
||||
[canSubmit, onSubmit],
|
||||
);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!disabled && value.trim().length > 0) {
|
||||
onSubmit();
|
||||
}
|
||||
if (canSubmit) onSubmit();
|
||||
},
|
||||
[disabled, onSubmit, value],
|
||||
[canSubmit, onSubmit],
|
||||
);
|
||||
|
||||
const canSubmit = !disabled && value.trim().length > 0;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-slot="coach-input"
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col gap-2"
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -151,12 +146,6 @@ export function CoachInput({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
data-slot="coach-input-disclaimer"
|
||||
className="text-muted-foreground text-[11px]"
|
||||
>
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Bot, Sparkles, User } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import type {
|
||||
@@ -62,7 +63,33 @@ export function MessageThread({
|
||||
// persisted twin lands — comparing on `messageId` keeps the
|
||||
// transition seamless while never accidentally hiding an in-flight
|
||||
// bubble (during streaming `messageId` is null).
|
||||
// v1.4.22 W5 reconcile (Code-MED-3) — streaming/persisted-twin
|
||||
// race. On slow connections SSE `done` fires before the
|
||||
// invalidate-refetch resolves; the persisted twin lands while the
|
||||
// streaming bubble is still painted, producing a 200-500ms
|
||||
// duplicate render. Hide the persisted twin (matched on
|
||||
// streaming.messageId) for a 150ms grace window after it first
|
||||
// appears so the streaming bubble stays alone until the streaming
|
||||
// state cleans up naturally on the next `send`.
|
||||
const [graceWindow, setGraceWindow] = useState(false);
|
||||
const lastPersistedIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const persistedId = streaming?.messageId ?? null;
|
||||
if (
|
||||
persistedId &&
|
||||
persistedId !== lastPersistedIdRef.current &&
|
||||
messages.some((m) => m.id === persistedId)
|
||||
) {
|
||||
lastPersistedIdRef.current = persistedId;
|
||||
setGraceWindow(true);
|
||||
const handle = setTimeout(() => setGraceWindow(false), 150);
|
||||
return () => clearTimeout(handle);
|
||||
}
|
||||
}, [streaming?.messageId, messages]);
|
||||
const suppressedTwinId = graceWindow ? streaming?.messageId : null;
|
||||
|
||||
const streamingPersisted =
|
||||
!graceWindow &&
|
||||
streaming?.messageId != null &&
|
||||
messages.some((m) => m.id === streaming.messageId);
|
||||
const streamingActive =
|
||||
@@ -119,15 +146,21 @@ export function MessageThread({
|
||||
"scroll-smooth",
|
||||
)}
|
||||
>
|
||||
{messages.map((m) => (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
/>
|
||||
))}
|
||||
{messages.map((m) => {
|
||||
// v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted
|
||||
// twin during the 150ms grace window so the streaming bubble
|
||||
// stays alone on slow connections.
|
||||
if (m.id === suppressedTwinId) return null;
|
||||
return (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{streamingActive && streaming && (
|
||||
// role=log + aria-live=polite so screen-reader users hear the
|
||||
// assistant prose announce as tokens land. aria-relevant=text
|
||||
@@ -144,6 +177,21 @@ export function MessageThread({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.4.22 W5 reconcile (Design-H3) — medical-disclaimer
|
||||
reach regression. v1.4.22 B4 moved the disclaimer to the
|
||||
sources-rail footer, but the rail is xl-only; on mobile
|
||||
and laptop viewports the disclaimer was only reachable
|
||||
via the chevron tray. Pin a small persistent line at the
|
||||
bottom of the message thread so every Coach session
|
||||
carries the disclaimer regardless of viewport. The rail
|
||||
footer stays for desktop users — the redundancy is
|
||||
intentional for clinical-adjacent UI. */}
|
||||
<p
|
||||
data-slot="coach-thread-disclaimer"
|
||||
className="text-muted-foreground mt-auto pt-2 text-[10px] leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +214,16 @@ function ChatBubble({
|
||||
errorCode,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
if (role === "user") {
|
||||
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
|
||||
// avatar matches the Coach avatar in size and visual weight.
|
||||
// Falls back to the generic person glyph when the user hasn't
|
||||
// configured an email (or the Gravatar lookup returned null).
|
||||
const gravatarUrl = user?.gravatarUrl ?? null;
|
||||
const initials = user?.username
|
||||
? user.username.slice(0, 2).toUpperCase()
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-user"
|
||||
@@ -181,12 +238,24 @@ function ChatBubble({
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<User className="size-3.5" />
|
||||
</div>
|
||||
{gravatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={gravatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-slot="coach-bubble-user-avatar"
|
||||
className="border-border/50 mt-0.5 size-8 shrink-0 rounded-full border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="coach-bubble-user-avatar"
|
||||
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"
|
||||
>
|
||||
{initials ?? <User className="size-3.5" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +271,8 @@ function ChatBubble({
|
||||
? t("insights.coach.errorProvider")
|
||||
: null;
|
||||
|
||||
const keyValues = metricSource?.keyValues ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-assistant"
|
||||
@@ -232,6 +303,55 @@ function ChatBubble({
|
||||
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{metricSource && <SourceChips provenance={metricSource} />}
|
||||
{keyValues.length > 0 && (
|
||||
<details
|
||||
data-slot="coach-evidence"
|
||||
className={cn(
|
||||
"border-border/50 bg-muted/30 group rounded-md border",
|
||||
"px-2.5 py-1.5 text-xs",
|
||||
)}
|
||||
>
|
||||
<summary
|
||||
data-slot="coach-evidence-summary"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex cursor-pointer",
|
||||
"items-center gap-1.5 leading-relaxed",
|
||||
"marker:hidden [&::-webkit-details-marker]:hidden",
|
||||
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-3 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
<span>{t("insights.coach.evidenceLabel")}</span>
|
||||
</summary>
|
||||
<ul
|
||||
data-slot="coach-evidence-list"
|
||||
className="text-foreground mt-2 flex flex-col gap-1"
|
||||
>
|
||||
{keyValues.map((kv, idx) => (
|
||||
<li
|
||||
key={`${kv.label}-${idx}`}
|
||||
data-slot="coach-evidence-row"
|
||||
className="leading-relaxed"
|
||||
>
|
||||
<span className="text-muted-foreground">{kv.label}:</span>{" "}
|
||||
<strong className="font-semibold">
|
||||
{kv.value}
|
||||
{kv.unit ? ` ${kv.unit}` : ""}
|
||||
</strong>
|
||||
{kv.window && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({kv.window})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -275,13 +275,21 @@ export function SourcesRail({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
|
||||
{/* v1.4.22 B4: the rail's footer now carries the medical
|
||||
disclaimer (relocated from below the composer). The
|
||||
source-picker section above stands on its own as the rail's
|
||||
primary content — users see the disclaimer once, in a calm
|
||||
place, instead of every time they look at the input. */}
|
||||
<div
|
||||
data-slot="coach-sources-disclaimer"
|
||||
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
|
||||
>
|
||||
<Activity
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.sourcesFooter")}
|
||||
{t("insights.coach.composerDisclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { CorrelationCard } from "./correlation-card";
|
||||
@@ -7,14 +8,18 @@ import { CorrelationCard } from "./correlation-card";
|
||||
/**
|
||||
* v1.4.20 phase B3 — Correlation row.
|
||||
*
|
||||
* Renders the three pre-defined hypothesis cards in a 2-up grid on
|
||||
* `>=md` and a single column on `<md`. A single correlation-disclaimer
|
||||
* footer sits below the row — the disclaimer applies to every card so
|
||||
* we don't repeat it per-card.
|
||||
* Renders the pre-defined hypothesis cards in a 2-up grid on `>=md` and
|
||||
* a single column on `<md`. A single correlation-disclaimer footer sits
|
||||
* below the row — the disclaimer applies to every card so we don't
|
||||
* repeat it per-card.
|
||||
*
|
||||
* The `<CorrelationCard>` itself owns the empty-state for below-
|
||||
* threshold results, so this row never has to filter — it always
|
||||
* mounts all three cards regardless of `status`.
|
||||
* v1.4.22 A4 — empty-state cards are dropped instead of rendered. Any
|
||||
* hypothesis whose `status !== "ok"` is filtered out so the layout
|
||||
* collapses cleanly: 2 ok cards → 50/50, 1 ok card → 100 %, 0 ok cards
|
||||
* → the row hides itself entirely (no header, no disclaimer). Up to
|
||||
* v1.4.21 the `<CorrelationCard>` painted an EmptyState for below-
|
||||
* threshold results, which left half-rows of greyed-out placeholders
|
||||
* on a sparse account.
|
||||
*/
|
||||
|
||||
interface CorrelationRowProps {
|
||||
@@ -28,6 +33,16 @@ interface CorrelationRowProps {
|
||||
export function CorrelationRow({ results }: CorrelationRowProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Drop insufficient-data tiles up-front. The grid below picks 1-col vs
|
||||
// 2-col automatically so 1 ok card spans 100 % width on its own row.
|
||||
const okResults = [
|
||||
results.bpCompliance,
|
||||
results.moodPulse,
|
||||
results.weightWeekday,
|
||||
].filter((r) => r.status === "ok");
|
||||
|
||||
if (okResults.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="correlation-row"
|
||||
@@ -42,10 +57,15 @@ export function CorrelationRow({ results }: CorrelationRowProps) {
|
||||
{t("insights.correlationRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CorrelationCard result={results.bpCompliance} />
|
||||
<CorrelationCard result={results.moodPulse} />
|
||||
<CorrelationCard result={results.weightWeekday} />
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4",
|
||||
okResults.length > 1 && "md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{okResults.map((result) => (
|
||||
<CorrelationCard key={result.kind} result={result} />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
data-slot="correlation-row-disclaimer"
|
||||
|
||||
@@ -238,33 +238,41 @@ export function HeroStrip({
|
||||
* current ISO week. Older parents that haven't adopted the
|
||||
* weeklyReportHref prop still get the disabled affordance.
|
||||
*/}
|
||||
{weeklyReportHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={weeklyReportHref}>
|
||||
{(() => {
|
||||
// v1.4.22 W5 reconcile (S-02) — collapse the
|
||||
// enabled/disabled twin Buttons (variant, size, slot,
|
||||
// class, icon, label all identical) into a single
|
||||
// shared structure with the Link wrapping conditionally.
|
||||
const weeklyLabel = (
|
||||
<>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroActionWeeklyReport")}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled
|
||||
title={comingSoon}
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroActionWeeklyReport")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return weeklyReportHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled
|
||||
title={comingSoon}
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{weeklyLabel}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{/* B2b wires this into the Coach drawer. The button is
|
||||
enabled whenever the parent supplies an `onAskCoach`
|
||||
handler; older parents that haven't adopted B2b yet
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import { MoodChart } from "@/components/charts/mood-chart";
|
||||
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
RecommendationFeedback,
|
||||
@@ -333,7 +334,9 @@ export function RecommendationCard({
|
||||
{norm.severity}
|
||||
</Badge>
|
||||
)}
|
||||
<p className="text-sm leading-snug">{norm.text}</p>
|
||||
<p className="text-sm leading-snug">
|
||||
{stripChartTokens(norm.text)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,17 +26,20 @@ import {
|
||||
* the row's visual rhythm.
|
||||
*/
|
||||
|
||||
// v1.4.22 W5 reconcile (S-04) — both `dynamic()` calls used the same
|
||||
// pulse-skeleton placeholder. Hoist so the loading affordance stays
|
||||
// in lock-step (cleared against `feedback_charts_visual_identity.md`
|
||||
// — placeholder only, no chart visual change).
|
||||
const ChartSkeleton = () => (
|
||||
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
);
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
{ ssr: false, loading: ChartSkeleton },
|
||||
);
|
||||
|
||||
const MoodChart = dynamic(
|
||||
@@ -44,12 +47,7 @@ const MoodChart = dynamic(
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
{ ssr: false, loading: ChartSkeleton },
|
||||
);
|
||||
|
||||
interface TrendsRowProps {
|
||||
@@ -95,8 +93,19 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* v1.4.22 A4 — equal-height cards. The AI annotation prose
|
||||
below each chart varies in length, which used to leave the
|
||||
three cards on visibly different baselines. Each card is now
|
||||
a flex column with `min-h-[300px]` so the chart anchors to
|
||||
the top and the annotation grows downward into a shared
|
||||
minimum height. The grid + min-h together preserve the visual
|
||||
rhythm even on a row where only one annotation is multi-line. */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div data-slot="trends-row-card" data-metric="bp" className="space-y-2">
|
||||
<div
|
||||
data-slot="trends-row-card"
|
||||
data-metric="bp"
|
||||
className="flex min-h-[300px] flex-col gap-2"
|
||||
>
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
@@ -114,7 +123,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
<div
|
||||
data-slot="trends-row-card"
|
||||
data-metric="weight"
|
||||
className="space-y-2"
|
||||
className="flex min-h-[300px] flex-col gap-2"
|
||||
>
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
@@ -132,7 +141,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
<div
|
||||
data-slot="trends-row-card"
|
||||
data-metric="mood"
|
||||
className="space-y-2"
|
||||
className="flex min-h-[300px] flex-col gap-2"
|
||||
>
|
||||
<MoodChart title={t("charts.mood")} mini />
|
||||
<TrendAnnotation
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("<SidebarNav> bug-report toggle", () => {
|
||||
});
|
||||
|
||||
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
|
||||
// v1.4.16 A1: Marc reported the global sidebar expanding admin
|
||||
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
|
||||
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
|
||||
// already provides per-section nav inside the page. The Admin entry
|
||||
// must behave EXACTLY like the Settings entry: a single link with no
|
||||
@@ -112,7 +112,7 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
|
||||
const html = render({ role: "ADMIN", pathname: "/admin/system-status" });
|
||||
expect(html).toContain('href="/admin"');
|
||||
// Sub-section links must NOT appear in the global sidebar — they
|
||||
// belong to `<AdminShell>`'s in-page nav. Marc reported the
|
||||
// belong to `<AdminShell>`'s in-page nav. the maintainer reported the
|
||||
// expansion as broken UX in v1.4.16; this guard keeps it gone.
|
||||
for (const section of ADMIN_SECTIONS) {
|
||||
expect(html).not.toContain(`href="/admin/${section.slug}"`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -419,7 +419,7 @@ export function OnboardingTour({
|
||||
|
||||
{/* v1.4.15 H4 design: tour footer buttons reach the WCAG 2.5.5
|
||||
44 px tap-target floor on mobile. The `size="sm"` default
|
||||
of `h-8` clipped to ~32 px which Marc reported as too small
|
||||
of `h-8` clipped to ~32 px which the maintainer reported as too small
|
||||
on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
|
||||
keeps the desktop visual close enough — the buttons grow
|
||||
~4 px taller — while making mobile usable. */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
|
||||
*
|
||||
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
|
||||
* The new section replaces the v1.4.15-vintage top/bottom split (the maintainer's
|
||||
* `feedback_settings_no_split.md`): a single Select picks the active
|
||||
* provider; the form below is switch-rendered for that provider; the
|
||||
* fallback chain at the bottom reorders / toggles entries with arrow
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* v1.4.19 phase A5 — IntegrationStatusPill
|
||||
*
|
||||
* The single tag rendered top-right of every integration card. The
|
||||
* pill is the ONLY place a connection status surfaces — Marc was
|
||||
* pill is the ONLY place a connection status surfaces — the maintainer was
|
||||
* staring at three- to four-fold redundancy across Withings + Mood Log
|
||||
* cards in v1.4.18 and called it out. This component is reusable so
|
||||
* v1.4.20 can drop the same pill on the Apple Health card.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
|
||||
*
|
||||
* Marc's verbatim concern (paraphrased):
|
||||
* the maintainer's verbatim concern (paraphrased):
|
||||
* - Withings card had 3 status displays — top-right "connected"
|
||||
* badge, mid-card "connected / last successful / last attempt"
|
||||
* trio container, and the badge inside that container.
|
||||
@@ -22,7 +22,7 @@
|
||||
* 4. Mood Log disconnected: pill says "Not connected", no other
|
||||
* status display surfaces.
|
||||
* 5. Mood Log card has the visual divider that Withings always had
|
||||
* — consistency Marc explicitly called out.
|
||||
* — consistency the maintainer explicitly called out.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -219,7 +219,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
expect(html).not.toContain("Last attempt");
|
||||
});
|
||||
|
||||
it("Withings reauth state surfaces 'Error — reconnect' on the pill (Marc's wording)", () => {
|
||||
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
|
||||
setIntegrationStatus({
|
||||
threshold: 3,
|
||||
integrations: [
|
||||
|
||||
@@ -266,7 +266,7 @@ export function AboutSection() {
|
||||
that proxies through `/api/version/check-updates`. The v1.4.2
|
||||
version called `api.github.com` directly from the browser, which
|
||||
the production CSP blocked silently — that's why "nothing
|
||||
happened" when Marc clicked the button. */}
|
||||
happened" when the user clicked the button. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
|
||||
@@ -168,7 +168,7 @@ export function AiSection() {
|
||||
/**
|
||||
* The single dropdown-driven configuration card. Replaces the
|
||||
* v1.4.15-vintage two-card layout (Codex connect at the top, personal
|
||||
* provider form at the bottom) per Marc's
|
||||
* provider form at the bottom) per the maintainer's
|
||||
* `feedback_settings_no_split.md`: one provider Select, one matching
|
||||
* form below, one fallback-chain card at the bottom.
|
||||
*
|
||||
@@ -389,7 +389,7 @@ function ActiveProviderSelect({
|
||||
|
||||
/**
|
||||
* Switch-rendered provider configuration. The component is the
|
||||
* "form below adapts dynamically" half of Marc's pulldown contract:
|
||||
* "form below adapts dynamically" half of the maintainer's pulldown contract:
|
||||
* pick Codex → see Codex form; pick OpenAI → see API-key + model
|
||||
* dropdown + base-URL override. The card frame is the same in every
|
||||
* branch so transitions feel like updating a single concept rather
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
@@ -54,7 +55,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
@@ -81,7 +82,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutSaveSuccess"));
|
||||
},
|
||||
@@ -95,7 +96,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutResetSuccess"));
|
||||
},
|
||||
@@ -114,7 +115,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
/**
|
||||
* v1.4.15 Fix 5 — independent toggle for the *strip tile* (the upper
|
||||
* row of trend cards). Until v1.4.14 a single switch controlled both
|
||||
* the tile AND the chart for the same metric, which Marc found too
|
||||
* the tile AND the chart for the same metric, which the maintainer found too
|
||||
* coarse: he wanted a chart visible without the tile (for metrics he
|
||||
* tracks without wanting the at-a-glance number) or vice versa.
|
||||
*/
|
||||
@@ -181,9 +182,17 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.customizeSubtitle")}
|
||||
</p>
|
||||
{/*
|
||||
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
|
||||
already renders a `settings.sections.dashboard.description`
|
||||
paragraph beneath the H1. The previous in-card help line
|
||||
repeated the same idea ("Choose which cards appear …
|
||||
Defaults work out of the box.") right next to the comparison
|
||||
picker, giving the page three muted-foreground help blocks
|
||||
stacked on top of each other. Removing this duplicate keeps
|
||||
the comparison picker as the only thing between the header
|
||||
and the widget table.
|
||||
*/}
|
||||
|
||||
{/* v1.4.16 phase B8 — comparison baseline picker. Lives at the top
|
||||
of the section because it changes how every chart + tile below
|
||||
@@ -242,8 +251,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
{/* v1.4.15 Fix 5 — table-style header naming the two
|
||||
switches. The "tile" column controls the strip tile in
|
||||
the upper row; the "chart" column controls the line
|
||||
chart in the lower row. Marc wanted independent control
|
||||
of the two surfaces (memory feedback_dashboard_top_tiles
|
||||
chart in the lower row. the maintainer wanted independent control
|
||||
of the two surfaces (per feedback_dashboard_top_tiles
|
||||
_selectable.md). */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<span className="w-5" aria-hidden="true" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
/**
|
||||
* v1.4.19 phase A5 — single status pill rendered top-right of every
|
||||
* integration card (Withings, Mood Log, Apple Health…). It replaces
|
||||
* the three- to four-fold redundant status surface Marc spotted in
|
||||
* the three- to four-fold redundant status surface the maintainer spotted in
|
||||
* v1.4.18:
|
||||
*
|
||||
* - top-right "Connected" badge
|
||||
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
|
||||
* - bottom-of-card "letzter Sync" line
|
||||
*
|
||||
* Now there is exactly ONE place a sync status can appear on a card
|
||||
* and it follows the pattern Marc liked from the Withings header: a
|
||||
* and it follows the pattern the maintainer liked from the Withings header: a
|
||||
* Dracula-tokenized chip with the relative time inline.
|
||||
*
|
||||
* The component is locale-aware (EN + DE) and mobile-safe: on a
|
||||
|
||||
@@ -136,7 +136,7 @@ function pillStateFor(
|
||||
* sync attempt failed. The pill conveys "something is wrong"; this
|
||||
* line tells the user *what* is wrong so they can act on it. Keeping
|
||||
* it deliberately small (one icon + one line) so it doesn't recreate
|
||||
* the v1.4.18 redundant banner Marc removed.
|
||||
* the v1.4.18 redundant banner the maintainer removed.
|
||||
*/
|
||||
function IntegrationErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
@@ -653,7 +653,7 @@ function MoodLogCard({
|
||||
</p>
|
||||
|
||||
{/* v1.4.19 A5 — visual divider matches Withings for consistency
|
||||
(Marc explicitly called the asymmetry out). */}
|
||||
(the maintainer explicitly called the asymmetry out). */}
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
@@ -721,7 +721,10 @@ function MoodLogCard({
|
||||
setMsgType("success");
|
||||
}}
|
||||
>
|
||||
{t("common.copied").replace("!", "")}
|
||||
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
|
||||
common.copy key instead of stripping the
|
||||
exclamation off common.copied. */}
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
@@ -733,7 +736,7 @@ function MoodLogCard({
|
||||
{/* v1.4.19 A5 — The "letzter Sync" line that used to
|
||||
live here is gone. The pill in the card header
|
||||
already carries that information; repeating it
|
||||
inside the body is the redundancy Marc flagged. The
|
||||
inside the body is the redundancy the maintainer flagged. The
|
||||
entry-count is the only number we still surface
|
||||
because it's an integration-specific datapoint the
|
||||
pill cannot convey. */}
|
||||
|
||||
@@ -258,7 +258,7 @@ function ChannelRow({
|
||||
|
||||
{/* v1.4.15 H4 design: action buttons hit the WCAG 2.5.5 44 px
|
||||
floor on mobile via `min-h-11`. Settings is the most-touched
|
||||
mobile surface; `size="sm"` here clips to ~32 px which Marc
|
||||
mobile surface; `size="sm"` here clips to ~32 px which the maintainer
|
||||
flagged as too small on the iPad/iPhone PWA shell. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{channel.state === "auto_disabled" && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "../tabs";
|
||||
|
||||
describe("<TabsList>", () => {
|
||||
it("clamps the y-axis so a fixed-height strip never paints a vertical scrollbar", () => {
|
||||
// Marc reported a stray "mini scroll-button" on the right side of
|
||||
// the maintainer reported a stray "mini scroll-button" on the right side of
|
||||
// the feedback inbox tab strip (Open / Acknowledged / Resolved /
|
||||
// Archived). Root cause: `overflow-x-auto` couples to `overflow-y`
|
||||
// — once one axis is `auto`, the other can no longer be `visible`,
|
||||
|
||||
@@ -42,7 +42,7 @@ function Tabs({
|
||||
// ~1 px taller than the strip on some glyph stacks, a tiny painted
|
||||
// vertical scrollbar appeared on the right edge of short strips
|
||||
// (e.g. the admin feedback `Open / Acknowledged / Resolved /
|
||||
// Archived` tabs Marc reported in v1.4.19). Locking the y-axis hides
|
||||
// Archived` tabs the maintainer reported in v1.4.19). Locking the y-axis hides
|
||||
// that bar without affecting horizontal swipe behaviour.
|
||||
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
|
||||
// (`focus-visible:ring-[3px]`) on triggers stays inside the
|
||||
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user