Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.
Waves shipped (each with a phase report under .planning/):
- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
integration contracts close audit Critical Finding #3 (soft-delete
invisibility) across dashboard, analytics, summaries, rollup tiers,
and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
/api/insights/{features,targets,cards} routes plus the
compliance-rollup hook gap closure for bulk-projection mints; the
post-QA reconcile patch closes the six remaining measurement.findMany
sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
queryKey factory-bypass walker test (extended to comparison/ in the
reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
time-sensitive APNs payload (priority 10 + interruption-level)
scoped via a parameterised test that pins all 6 other event-types
do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
append-only invariant pinned in test; revoke-then-regrant leaves
both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
(week/month/year) wired through summaries-slice and
health-score-fast-path with a linear-composability parity test
pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
eliminates a sibling-drift risk; knip whitelist trimmed to three
files pending follow-up.
QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):
- senior C1 — dashboard-suspense-boundaries regex aligned with the
post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
compliance recompute on both /api/medications/intake and
/api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).
Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.
User-facing prose: see CHANGELOG.md.
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.
Changed:
- /api/insights/comprehensive now reads through a SQL-side aggregator
(src/lib/insights/comprehensive-aggregator.ts) grouping by metric
type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
the measurements (user_id, type, measured_at) index path. Returns
the same DataSummary-shaped per-type bundle the legacy route
stitched together; the medication-compliance block keeps its
bounded Prisma reads. Wrapped through the server cache keyed on
`${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
to a few hundred ms against fixture data matching the production
volume that triggered the bug; warm path resolves in single-digit
milliseconds.
- Cache-wrap the five remaining read routes the v1.4.34 server-cache
blueprint left staged but unwired: /api/mood/analytics,
/api/workouts, /api/bugreport/status, /api/medications,
/api/dashboard/widgets. The Cloudflare 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream
consequence — once the comprehensive route stops monopolising the
pool, the origin accepts new connections fast enough that
Cloudflare's circuit-breaker no longer trips.
- /api/dashboard/chart-overlay-prefs PUT now invalidates the new
dashboard-widgets cache so the partial-update path doesn't leave
stale layout state in the cache for up to TTL.
Fixed:
- Scatter-correlation card no longer logs the Recharts
`width(-1)/height(-1)` warning on first mount. The wrapper now
carries a min-h-[180px] floor that matches the loading skeleton so
ResponsiveContainer always measures a non-zero parent before
aspect-ratio settles.
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
Daily-step aggregation hotfix. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages, by routing cumulative HealthKit types through the sum branch of the client-side daily aggregator.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
The BD-Zielbereich tile is the only tile that ships an `avgAllTime`
sub-value (W2 A1+A2). Combined with the comparison-delta callout
above it, the tile rendered four stacked rows below the headline on
mobile: the comparison delta, then 7d/30d/all-time on a single
flex row that wrapped to three lines at 280 px (Galaxy Fold). The
BP tile read 2× as dense as every other trend card.
At `<sm`: hide the all-time span on the 7d/30d row, hide the
standalone comparison-delta callout, and render a single combined
secondary row beneath: "All-time 11% · Δ −0.4 last month". Full
layout returns at `>=sm` for users with the screen real-estate.
Closes Design-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.
CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.
CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).
Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
that didn't ship the standard tile feature set. Other tiles paint a
trend arrow next to the headline, a 7-day-trend chip on the `7d:`
sub-row, and a comparison-overlay caption when the dashboard's global
compare toggle is active. The BP-in-target tile shipped none of these
because we only had a single % series instead of a `DataSummary`.
Synthesise the slope from the difference between the 7-day and 30-day
in-target shares: a recent week above the recent month means the
metric is improving (up-good ⇒ green arrow); below means slipping.
The 7-day-trend chip uses the same delta. The comparison overlay uses
`bpInTargetPctAllTime` as the long-arc baseline so the caption reads
correctly when comparison is on.
Extend `<TrendCard>` with an optional `avgAllTime` sub-value so the
all-time aggregate stays visible alongside `7d:` and `30d:` after the
v1.4.22 A1 headline re-anchor. Other tiles leave the field undefined
so the third sub-row never renders for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an additive annotations[] prop to <HealthChart> that paints a
vertical Recharts <ReferenceLine> per AI-narrated event on the
underlying timeline. The line + tooltip + comparison overlay remain
untouched — annotations sit between the cartesian grid and the data
lines so they read as orientation, never as data.
The /insights page wires advisor.payload.insights.storyboardAnnotations
into the 90-day BP chart, mapping the four canonical categories to
Dracula colours (medication=pink, event=cyan, milestone=green,
warning=orange).
Pure helper resolveAnnotationPositions() handles the date->pointIndex
mapping with a 7-day snap window (so annotations don't stack on
monthly buckets) and a 24-char truncation for the small-viewport
label. The unit suite pins exact-day pinning, near-day snapping,
out-of-window dropping, and label truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pixel-5 audit against PROD (393 px) showed two issues with the
chart cards on /dashboard and /insights:
1. The bucket-aggregation chip ("Wochendurchschnitt" / "Weekly avg"),
the optional comparison-period chip, the four range tabs and the
cog dropdown all sat on a single justify-between row in the chart
header. On viewports below the sm breakpoint the chips ate enough
horizontal space that the tabs wrapped to a second row inside the
same flex container, doubling the header height. On Galaxy Fold
compact (280 px) the tabs split across three or four rows and the
medication card overflowed horizontally.
2. The medication compliance chart drew one tick per data point on
the x-axis. A 30-day Pixel-5 window painted thirty overlapping
labels into a 393 px gutter while the weight / BMI charts looked
sparser by accident, so the chart row read inconsistently.
The header is now stacked into two rows below the sm breakpoint:
title + chips on row 1, range tabs + cog right-aligned on row 2 with
flex-nowrap so the tabs always fit. The decorative bucket-aggregation
chip and comparison captions hide on mobile (the range tabs already
communicate the visible window). Above sm the original side-by-side
layout is preserved.
A new helper at src/lib/charts/x-axis-density.ts caps visible ticks
at 4 (≤360 px Fold), 6 (≤480 px Pixel-5 / iPhone-12), 8 (small tablet)
and 10 (desktop) and is wired into HealthChart, MoodChart,
MedicationComplianceChart and ComplianceLineChart so every chart row
reads the same density. A new useViewportWidth hook keeps it reactive
without an SSR penalty (desktop default during server render, real
measurement after mount).
The scatter-correlation chart was deliberately left alone — its
x-axis is numeric (BP / weight values, not a time series) and already
supplies an explicit `ticks` array.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The settings-cog trigger was 28x28 CSS px (h-7 w-7), below the
WCAG 2.1 AA 44x44 minimum. Sat in the same flex row as the 44 px
range tabs, so a Pixel-5 thumb missed the cog more reliably than
the surrounding tabs. Switch to min-h-11 min-w-11; icon stays at
h-3.5 w-3.5 so visual rhythm doesn't bloat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MoodChart component defaulted chartKey to "mood", which meant
every consumer that omitted the prop — including /insights and the
recommendation-card mini chart — picked up the dashboard's persisted
overlay prefs and rendered the settings cog. Toggling Trend on
/insights would silently flip the dashboard mood chart on next paint
because both reads shared the same persistence key.
Drop the default; the dashboard now passes chartKey="mood" explicitly
and ad-hoc surfaces stay clean (no cog, default overlays). Also wraps
the title row's right-side container with flex-wrap + justify-end so
the 4 range tabs + cog don't push the card horizontally on Pixel-5
when the comparison caption is also in the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven mechanical cleanups, no behaviour changes:
- Drop the duplicate ChartOverlayPrefs type + DEFAULT in
chart-overlay-controls; re-export the canonical pair from
dashboard-layout so a future fourth toggle can't drift between
the UI and persistence layers.
- useChartOverlayPrefs accepts null/undefined chartKey and short-
circuits the dashboard-layout fetch when none is supplied. Drops
the chartKey ?? "bp" fallback and the three discard-the-result
ternaries in health-chart; mini-mode renders no longer subscribe
to the BP chart's persisted prefs.
- /api/analytics no longer pair-and-counts BP twice — the windowed
helper already produces last30Days, so the headline reads it back
instead of running computeBpInTargetPct on the same input.
- Drop the redundant moodEntries.map shape in the achievements
route; the prisma select already returns the matching shape.
- Merge the five hidden-metric cases into the no-precondition block
of isEarnable; the defensive comment admitted they couldn't fire.
- Remove the deprecated getAchievementCategory wrapper (zero callers).
- Strip six "v1.4.18 gradient removed" what-comments scattered
across the chart wrappers; the polish tests pin the absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Playwright spec that drives the dashboard end-to-end — opens
the chart overlay-controls popover, toggles target-range on, and
verifies the PUT /api/dashboard/chart-overlay-prefs round-trip fires.
Adds a vitest SSR test that pins the medication-compliance chart's
prefs-gated rendering: with the trend toggle off the 7-day trend chip
stays hidden; flipping it on paints the chip in the header. Mocks the
useChartOverlayPrefs hook so each test can dial the persisted state
directly without round-tripping the layout API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.
Wires up:
- GET /api/dashboard/widgets surfaces the resolved prefs in its
response so a fresh dashboard load knows which overlays to paint.
- PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
prefs without round-tripping the whole layout.
- Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
the client doesn't send, so saving widget visibility doesn't wipe
chart prefs.
- useChartOverlayPrefs hook reads + optimistically updates the cache.
- HealthChart, MoodChart, MedicationComplianceChart all consume the
hook, render the controls dropdown next to the range tabs, and gate
their three overlays behind the persisted toggles.
Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each dashboard chart card gets its own settings cog in the top-right
corner that opens a small popover with three independent switches:
- 7-day trend (the moving-average overlay + numeric delta chip)
- Trend arrow (the regression line + arrow direction + personal
baseline reference line — all the "your normal" affordances)
- Target range (the BP / BMI / medication threshold shading and
reference lines)
The component is controlled — parents own the prefs object and pass
an onChange callback. Default state is everything OFF; the chart
loads as a clean line and the user opts in to whichever overlays
they want, per chart, independently.
Adds the matching i18n keys (EN + DE) for the popover label, the
three switch labels, and the trigger button's aria-label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 90-day-rolling-median baseline used to render unconditionally on
every health-chart and the mood-chart as a faint dashed reference line
labelled "Your normal" / "Dein Mittel". Marc's rule is that the mean
should only appear when a trend is actively being displayed, not as a
default chrome.
Gate the baseline ReferenceLine behind the Trend toggle on both
HealthChart and MoodChart. Default state stays OFF, so a fresh chart
loads as a clean line without the dashed mean. Toggling Trend on
brings back the baseline alongside the regression line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc explicitly rejected smileys in the mood chart. Drop the emoji
glyph map and the SVG <text> dot factory; the line now renders plain
Recharts dots and the y-axis already labels each integer (very low /
low / okay / good / great), so the chart stays scannable without a
glyph.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Apple-Health-style gradient area underneath each chart line muddied
the visual. Drop the linearGradient defs and the area-fill primitives on
every chart wrapper and let the line stroke speak for itself. Tooltip,
animation, and Dracula tokens stay as they were.
Also delete the chart-gradient module — there are no remaining call
sites once the fills come out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comparison overlay was added to BP / weight / pulse / mood charts
in B8 but the medication-compliance heatmap doesn't render a
prior-period overlay (the underlying intake-event window comparison
is deferred to v1.4.17). Without an explicit indicator the user
toggling Vormonat would see this card stay static and read it as a
broken toggle. Added a short "Comparison N/A for compliance" caption
under the header when the global compareBaseline is active so the
asymmetry is visible. EN+DE i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compareDelta `aria-label` interpolated `"vs. last month"` and
`"vs. last year"` as literal English strings regardless of locale.
A DE user got an English screen-reader announcement next to a
visible German caption ("Δ −2.3 kg vs. Vormonat"). Routed through
`t("comparison.captionLastMonth" | "comparison.captionLastYear")`
so screen-reader output matches the visible text in both locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WCAG 2.5.5 minimum hit-area is 44×44 CSS px and the Wave-C polish set
that as the project floor. Several v1.4.16 surfaces missed it:
- Recommendation thumbs (h-7 = 28 px) — `<RecommendationFeedback>` up
+ down both bumped to `min-h-11 min-w-11` with focus-visible ring.
- Recommendation chevron expand toggle (~24 px, no focus state) —
`<RecommendationCard>` rec-expand-toggle bumped to `min-h-11
min-w-11` with focus-visible ring.
- Chart range tabs (7d/30d/90d/All) — `min-h-9 px-2.5` ≈ 36×28 px on
Pixel 5; bumped to `min-h-11 px-3` across HealthChart, MoodChart,
MedicationComplianceChart, ComplianceCharts, ComplianceLineChart.
- Medication CSV "include intake history" raw `<input
type="checkbox">` (16×16, system-blue on iOS Safari) — replaced
with shadcn `<Switch>` inside a `min-h-11` label so the row is
properly tappable and visually consistent with every other
settings toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.
TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.
Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.
The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `compareBaseline` prop to HealthChart and MoodChart that, when
set to "lastMonth" or "lastYear", overlays the prior-period series as
a dimmed dashed line beneath the current data. The shift is computed
in-memory by `shiftDailySeriesForward` — the chart already fetches the
full measurement history so no extra API call is needed.
The tooltip switches its delta line from "vs. your normal" to "vs. last
month / last year" when comparison is active and a prior value exists
for the hovered day, and adds a second row with the prior absolute
value so both numbers are visible. The chart header gains a
"vs. last month / last year" caption (or an "unavailable — no data
yet" fallback when the prior period has no overlap).
The dashboard page and the insights page both read the persisted
preference from `User.dashboardWidgetsJson.comparisonBaseline` so a
single toggle flip in Settings → Dashboard updates every chart on
both surfaces atomically.
Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with
11 unit tests pinning the day-exact forward shift, the average helper,
and the delta computation (null-safe).
Regression guard: when comparisonBaseline is "none" (default), the
chart renders exactly as before — no extra paint, no caption, no
tooltip change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the health-chart B5c mini contract for the mood chart:
- mini: drops the range tabs, the toggle row (moving-average / trend
/ target-bands), and shrinks the chart height to 140px so it fits
inside the rationale card without dominating it. Emoji glyphs
+ lavender gradient + tooltip survive intact.
- windowOverride: pins the rangePoints state to the rationale enum
value on mount.
The mood chart's metric-source key is "mood" (not a measurement-type
enum value), so the recommendation-card metric-mapping helper routes
mood-rationale charts here rather than through HealthChart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Oura-style RecommendationCard (B5c) embeds a small chart pinned
to the recommendation's rationale.dataWindow. HealthChart gains two
opt-in props:
- mini: drops the range tabs, the moving-average / trend / target-
band toggle row, and shrinks padding (h-[140px] vs h-[240px]).
Tooltip + gradient + personal-baseline behaviour stay intact so
the small chart still tells the same visual story.
- windowOverride: pins the rangePoints state to the rationale enum
value (last7days / last30days / last90days / allTime) regardless
of any parent UI state. Mini mode hides the range tabs anyway,
so the user can't manually override the pinned window.
The pure helper resolveMiniRangePoints() (`mini-window.ts`) maps the
rationale enum onto the existing per-points TIME_RANGES_KEYS values.
Single source of truth so future schema changes touch one file.
7 new tests in `health-chart-mini.test.tsx` pin the contract:
- enum-to-points mapping for all 4 windows
- mini mode hides toggles + range tabs
- non-mini mode still renders them (regression guard)
- windowOverride seeds the rangePoints state on mount
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1.4.16 B1a polish across all dashboard charts. The
medication-compliance chart already shipped 7-day-trend + target/
threshold reference lines from A6; this commit brings it into visual
parity with BP/weight/pulse/mood:
- ComposedChart replaces LineChart; a gradient-filled Area paints
under the compliance line. The medication gradient uses the purple
Dracula token, matching the existing line stroke.
- Rich tooltip via RichChartTooltip — date label, compliance
percentage with tabular numerals, delta-vs-target sub-line that
reads "−15 pp vs. target" so the gap to 100 % is immediately
visible.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 daily points.
Existing "no scheduled doses" empty state is preserved (the user
could have a long history of skipped scheduled days that drops
the chart to fewer than 3 actionable points).
The 80 % minimum-acceptable threshold and 100 % goal ReferenceLines
from A6 stay untouched; gradient plus those lines now make the
"target range" feel like a band rather than a pair of guides.
1 new test pins the gradient SSR contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
paint under the line. Each metric gets its own
chart-gradient-inline-{type} <linearGradient> definition; default
35 percent top alpha → 0 percent bottom matches the Apple Health
Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
painted as a faint dashed line labelled "Your normal" / "Dein
Mittel". The new computePersonalBaseline() helper returns null when
fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
with drop shadow, per-row coloured dot, tabular-nums value cell, and
a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
rows from the moving-average / trend overlays are filtered out so
the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
isAnimationActive prop, suppressed when prefers-reduced-motion is
set at the OS level (new src/lib/charts/reduced-motion.ts helper
with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
daily points exist — a near-empty chart looks broken, the
placeholder reads as intentional.
In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.
7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.
Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."
The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:
- 7-day trend chip in the header: signed delta in percentage points
with a metric-aware sentiment colour (rising compliance → green,
falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
mean of the most-recent 7 daily rates minus the mean of the prior 7
(capped at a 14-day window). Mean-difference rather than slope so a
single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
(yellow, paler dash). Two lines together visualise the target range
the way HealthChart's targetZone band does for BP/weight tiles.
Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
assertion that the trend chip is suppressed in the empty state
(so the empty-state header doesn't paint a misleading "0 pp"
indicator).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported clicking "All" on a chart's range buttons displayed
"+0.0 kg/Woche" even when his weight clearly drifted across years.
Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays *
7` is mathematically correct but the per-week rate becomes
vanishingly small for multi-year windows (~0.07 kg/week for
10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)`
formatter rounds it to "0.0".
Fix: extract a pure `computeWindowTrend()` helper at
`src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we
additionally surface a split-half delta — the mean of the
second half of the visible series minus the mean of the first
half. That number cannot round to zero unless the metric truly
didn't move, and it's exactly what Marc wants to see when he
asks "what changed across all my data?". For short windows the
per-week rate stays the only display because it's still the most
actionable metric there.
The chart trend line surfaces both: "WEIGHT: +0.1 kg/week
(0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a
multi-year account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did
i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
"7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
the avg7 line is preserved for tiles that haven't been migrated to
the trend7Delta API.
trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
7 days) when slope7 is null. Mood entries are typically logged
less than daily, so slope7 needs ≥ 2 points within the trailing
7-day window — for sparser users that came back null and the tile
silently dropped the delta indicator. slope30 still answers "is
this metric drifting?" so we project that onto the 7-day window
for label parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.
Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:
1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
2.5.5 / Apple HIG / Material 44px minimum. Bumped to
`min-h-9 px-2.5` (36px) — that's still compact enough to fit in
the chart header strip on a 393px viewport but raises tap-target
pass rate substantially. Files: health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
compliance-charts.
2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
the dashboard charts used `scale-75` to shrink the Switch primitive
to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
Switch returns to its default size. Files: health-chart, mood-chart.
3. Medication card primary actions ("Taken" / "Skipped"): these are
the most-tapped controls in HealthLog (every dose intake is one
tap). Were `size="sm"` (32px). Bumped to default size + explicit
`min-h-11`. The history + edit icon-buttons on the same card go
from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
16px so the visual weight doesn't change.
4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
mobile card branch only — the desktop table keeps its denser
`h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
gains a `mobile` prop so the existing desktop callsite is
unchanged.
Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons
Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A5 mobile audit / B-mobile CRITICAL #1: Recharts wraps every
chart in a `<ResponsiveContainer>` whose default `touch-action: auto`
makes the browser wait for the chart's JS touchmove handler before
starting page scroll. Recharts then dispatches a Redux action +
tooltip re-render synchronously per touchmove. On slow devices that
manifests as Marc's "Scrolling-Hänger im Dashboard auf Charts".
Add the Tailwind `touch-pan-y` utility on every chart's wrapping
`<div>` so the browser starts vertical scroll immediately without
waiting for the JS handler. Chart-internal gestures (tap for tooltip,
horizontal swipe inside the chart) still flow through to JS.
Affected charts: health-chart, mood-chart, medication-compliance-chart,
compliance-line-chart, scatter-correlation-chart. The latter two were
rendering `<ResponsiveContainer>` without an explicit wrapper div, so
both gain a `<div className="touch-pan-y">` parent.
Evidence: new `touch-action-guard.test.ts` reads each chart source and
asserts `touch-pan-y` (or the inline `touchAction: "pan-y"` style) is
present. The test was first verified to fail on every chart before the
fix, then turned green after.
Findings ID: A5/CRITICAL-#1 (top of `.planning/phase-A5-mobile-findings.md`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The other dashboard charts (BP, weight, pulse, body fat) auto-bucket
to weekly when the visible range exceeds 90 days and to monthly past
730 days, with a small chip in the header that names the active
bucket. The mood chart used to show raw daily points only — multi-
month windows turned into a wall of dots that the eye couldn't read.
Apply the same `pickBucket()` thresholds and `bucketTimeSeries()`
helper the rest of the dashboard already uses (introduced in v1.4.6,
commit `6a64df0`). Aggregation is the mean mood-score per bucket, the
same shape `<HealthChart>` produces for the other metrics. Empty
buckets are skipped, never reported as 0, so a sparse user does not
get a flat zero baseline.
Header chip — the same `bg-muted/40` 10 px uppercase pill that
`<HealthChart>` paints, gated on `activeBucket !== "day"` so the
default short-range view stays uncluttered. Reuses `charts.bucketWeekly`
/ `charts.bucketMonthly` i18n keys.
Tests:
- `pickMoodBucket()`: returns "day" for ≤ 90d, "week" for 91-730d,
"month" for > 730d, "day" for empty / single-point series.
- `aggregateMoodEntries()`: returns daily points unchanged for short
windows; produces weekly buckets (≪ daily count, mean preserved)
for medium ranges; produces monthly buckets for multi-year ranges.
- Component SSR smoke: a 30-point series spaced 5 days apart spans
~150 days and surfaces the "Weekly avg" chip in the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `/admin/api-tokens` table rendered six full-width columns at every
breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
the card and forced the document itself to scroll horizontally — a
clear violation of the v1.4.x mobile-viewport contract guarded by
`e2e/mobile-viewport.spec.ts`.
Hide three lower-priority columns at narrow widths and surface the
hidden user inline so no information is lost:
- User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile
the username paints under the token name as a small secondary line.
- Last-used column: `hidden md:table-cell` (resurfaces ≥768px).
- Created column: `hidden md:table-cell`.
- Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS
px on each side at mobile widths.
The `<div className="overflow-x-auto">` wrapper stays as a
belt-and-suspenders guard for ultra-narrow viewports where even the
trimmed three-column layout might overflow (very long permission lists
or long token names). Without it the table would push the card itself
wider than the viewport.
Add `api-token-overview-responsive.test.tsx` to lock the contract:
the user / last-used / created columns must keep their
`hidden + (sm|md):table-cell` classes, the inline mobile fallback
span (`sm:hidden`) must duplicate the username so hiding the column
doesn't drop data, and the card root must keep the responsive
`p-4 sm:p-6` padding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>