The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.
Reads wrapped (60 s TTL unless noted):
- GET /api/mood/analytics → caches.moodAnalytics, key userId
- GET /api/workouts → caches.workouts, key includes
all params (limit/offset/since/
until/sportType)
- GET /api/bugreport/status → caches.bugreportStatus, key
"singleton" (10 min TTL);
isAdmin layered per request
- GET /api/medications → caches.medications, key userId
- GET /api/dashboard/widgets → caches.dashboardWidgets, key
userId (5 min TTL)
Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.
The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.
The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
The /api/insights/comprehensive handler used to walk every measurement
of every type from the trailing 90-day window into JS, then ran
summarize(), BMI, BP classification, target adherence, and four Pearson
correlations against the same pile of rows. Post Apple Health import the
unbounded findMany routinely returned 100 000+ rows, dominated request
time (~29 s in a production HAR capture), and starved the connection
pool — sibling endpoints cascaded into Cloudflare 503s.
Replace with a SQL-side aggregator in
src/lib/insights/comprehensive-aggregator.ts:
- Per-type DataSummary built via $queryRaw with AVG / MIN / MAX /
STDDEV_POP / REGR_SLOPE / REGR_R2 on the
measurements (user_id, type, measured_at) index. Anomaly count uses
STDDEV_POP to match the JS divisor (n, not n-1) and the same
round-then-threshold ordering as summarize().
- DISTINCT ON (type) for the latest raw value per type.
- date_trunc('day') daily means for correlation pairings.
- Two bounded findMany for BP sys/dia raw rows so the 5-minute
pairByTimestamp contract for bpPctInTarget survives byte-identical.
- One conditional MIN(measured_at) for dataSpanDays.
Route handler split into a buildComprehensiveResponse() helper wrapped
through caches.analytics (60 s TTL) keyed on `${userId}|comprehensive`.
The 60 s TTL converts every consumer within the window — page mount,
Coach drawer, recommendations grid — to a Map lookup; cold path drops
to a few hundred ms against fixture data matching the production volume
that triggered the bug.
One sanctioned semantic divergence: weight × BP correlation switches
from pairByTimestamp(24 h tolerance) to a daily-key join. The directive
authorised the swap for parity with the daily-mean shape mood × X
correlations already used; consumers comparing exact r/n against legacy
output may observe small drift.
Unit + integration tests cover the aggregator shape, cache hit / miss
counters, wide-event annotations, and end-to-end envelope parity.
The scatter-correlation card wrapper relied solely on Tailwind's
aspect-ratio utilities to derive its height from width, and explicitly
unset any inline height. When the dynamic-loaded chart mounted in
place of the h-[180px] skeleton, ResponsiveContainer briefly measured
a zero-sized parent before the browser resolved aspect-ratio against
the parent's width, emitting a width(-1)/height(-1) console warning.
Add a min-h-[180px] floor (matching the loading skeleton) so the
wrapper always has a non-zero height at first paint. The aspect-ratio
rules continue to drive the final card shape once layout settles.
Three integration tests against the real Postgres container pin the
v1.4.34 IW-G contract:
1. Ten concurrent /api/analytics reads against a cold cache fan into
a single builder call (misses === 1; remaining 9 split between
stampede joiners and warm-cache hits, with no second miss).
2. Priming analytics then writing a measurement evicts the cache —
the next read misses again and the response reflects the new row.
3. Compliance buckets re-read after an intake POST flush the cache
and surface the new taken-count.
Drops the unused _request parameter from buildAnalyticsResponse() so
the analytics route lints clean. CHANGELOG carries one Added line per
the marathon's user-facing voice rules.
Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.
Endpoints wired (per blueprint §6 matrix):
POST/DELETE /api/measurements
PUT/DELETE /api/measurements/[id]
POST /api/measurements/batch
DELETE /api/measurements/by-external-ids
POST/PUT/DELETE /api/mood-entries[/id]
POST /api/mood-entries/bulk
POST/PUT/DELETE /api/medications[/id]
POST /api/medications/[id]/intake
PUT/DELETE /api/medications/[id]/intake/[eventId]
POST /api/medications/intake/bulk
POST /api/workouts/batch
PUT/DELETE /api/dashboard/widgets
PUT /api/admin/settings (bug-report toggle path)
Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
/api/analytics — wraps both the slim (?slice=summaries) and default
slice paths through caches.analytics, keyed on ${userId}|${slice}.
TTL 60s. The default builder lifts into buildAnalyticsResponse() so
the cache miss / hit boundary stays at the route entry.
/api/gamification/achievements — wraps the locale-agnostic web result
through caches.achievements keyed on userId. TTL 60s. The iOS-format
locale transform runs after the cache read so the cache stays format-
neutral and the dashboard's duplicate mount (HAR-captured) coalesces
through single-flight.
/api/medications/intake?scope=compliance — wraps the per-day
compliance bucket build through caches.medicationsIntake keyed on
${userId}|compliance|${days}|${userTz}. TTL 15min because daily
buckets are slow-moving. The POST handler invalidates the per-user
medications/compliance/achievement caches after committing the dose
status change.
Each cached() call hands annotate() to the wrapper so cache.<name>.
outcome (hit|miss|stampede) + cache.<name>.key_hash land on every
request's wide event.
Extends the v1.4.33 Coach snapshot LRU shape into a reusable
ServerCache<T> with TTL expiry, capacity-bounded LRU eviction,
single-flight read coalescing, prefix delete, and per-instance
hit/miss/eviction/stampede counters. Adds five invalidation helpers
keyed by the per-user prefix scheme so write endpoints can flush every
cache that may reflect their changed rows in a single call.
The cached() wrapper attaches cache.<name>.outcome + cache.<name>.key_hash
to the active wide event so production logs carry the hit ratio without
leaking the userId portion of the cache key.
Hand-rolled to keep the dependency surface at zero — same recipe as the
snapshot cache the dashboard already relies on. Process-local; multi-
instance staleness is bounded by TTL, the Redis migration path stays a
future option.
The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.
Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
`src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
stays scoped to `/insights/**` because the floating action would distract
from the dashboard hero on surfaces with a contextual inline CTA.
Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
the button silently disappears when the Coach surface is disabled.
The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
The Insights tab strip carried fourteen pills end-to-end after the
v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics.
On a `<sm` viewport that meant Overview + roughly two-and-a-half
metric pills before the right-edge fade hid the rest.
The five wave-A entries (HRV, Resting HR, Oxygen, Body Temperature,
Active Energy) collapse behind a single "Vitals" / "Vitalwerte"
parent pill that opens a popover sub-list. Each sub-page keeps its
own URL and own page component — only the strip presentation
changes, so deep-links, bookmarks, and the iOS app's surface paths
all resolve unchanged.
Implementation:
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
`sub-page-metric.ts` flag a sub-page as belonging to a parent
pill. Adding a new group is one row in `SUB_PAGE_GROUP` plus a
matching label key under `insights.tabStrip.<group>Parent.*`.
- `<InsightsTabStrip>` discriminated `TabEntry` union — flat
`<Link>` pills coexist with `Popover`-driven group entries.
Availability gating sits on the leaf slugs: the parent pill is
only rendered when at least one of its children has data.
- Parent-pill active state mirrors the URL — when a user is on any
vital-sign sub-page, the parent pill carries `aria-current="page"`
and the `border-primary` style so spatial orientation survives
the collapse.
Reduces the strip footprint from 14 to 10 entries when every metric
has data — Overview + Blood Pressure + Pulse + Vitals (parent) +
Weight + BMI + Workouts + Sleep + Mood + Medication.
Per `.planning/research/v1434-r-2-carryover-scope.md` §8 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 1).
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.
`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.
`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.
The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.
Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
Adds 36 unit + contract tests across 7 files:
* `import-apple-health-export.test.ts` — end-to-end parse of a tiny
hand-authored fixture (spot weight, cumulative steps fold, sleep
stage, workout with sport mapping, deferred + unknown identifiers,
clinical record skip). Re-import idempotency: a second run reports
0 inserts + N updates. Memory ceiling: synthetic 10 000-record
export keeps heap delta under 100 MB.
* `hk-workout-activity-type-map.test.ts` — every value referenced
in the map exists in the canonical `workoutSportTypeEnum` union;
the iOS 18 canonical roster is covered; unknown identifiers fall
through to `"other"` with a falsy `known` flag.
* `unzip-export-xml.test.ts` — central-directory walker round-trips a
hand-built single-entry deflate ZIP; extractor throws on a missing
`export.xml` member.
* `stream-to-disk.test.ts` — boundary parser; named-field capture +
SHA-256 + text-field collection; size-cap enforcement; boundary-
missing error path.
* Route tests — auth gate (401), 403 on non-admin to the admin
variant, 413 on content-length cap, 429 on rate-limit; status
endpoint admits owner + triggering admin and 404s otherwise.
v1.4.34 IW-F-Perf — three changes.
1) New `use-achievements-query.test.tsx` mounts three consumers under
one TanStack QueryClient and asserts the shared queryKey collapses
them into a single network call. Pre-fix the notifier issued its
own request alongside the card; the regression guard surfaces it
immediately if a future drift reintroduces a per-user
discriminator.
2) Refresh `recent-achievements-card.test.tsx` and
`achievements/__tests__/page.test.tsx` to mock the new shared hook
directly instead of the raw `@tanstack/react-query` `useQuery`
shim. The two test suites stay scoped to render-shape, not the
network plumbing.
3) Capture the `/api/analytics` slim-vs-thick audit in
`.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
thick; the checklist is correctly slim by IW2 design but is
mounted inside the dashboard render tree so its slim call
duplicates the thick one already in flight. Documented two clean
migration paths and flagged the work for v1.4.34.x behind IW-B's
in-flight slim-slice routing.
v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.
Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
v1.4.34 IW-F-Perf — three consumers
(`<RecentAchievementsCard>`, `/achievements` mother page,
`<AchievementUnlockNotifier>`) each opened their own `useQuery`
block against `/api/gamification/achievements`. Two of them collided
on the same `["gamification", "achievements"]` literal; the notifier
carried a per-user discriminator on its key so TanStack treated it
as a fresh cache cell. Dashboard cold mount fired the same endpoint
twice.
The hook mirrors `use-analytics-query.ts`: centralises the queryKey
via `queryKeys.gamificationAchievements()`, 60s staleTime, no
refetch-on-mount, optional `refetchInterval` for the notifier. One
cache cell, one network call, no behavioural change for the toast.
Three new routes wire the streaming parser to the wire:
* `POST /api/import/apple-health-export` — user-facing kick-off.
Streams the multipart upload straight to `/tmp` (no in-memory
buffer for the 1.5 GB hard cap), hashes the bytes inline for
content-based idempotency, creates an `ImportJob` row in
`queued`, and enqueues the `apple-health-import` pg-boss job.
Returns `202 { jobId }` so the client can poll for progress.
* `GET /api/import/apple-health-export/[jobId]/status` — polling
endpoint returning the canonical `ImportJobStatusResponse`
envelope (R-1 §8). Authorises on owner-id OR triggering admin.
* `POST /api/admin/import-apple-health-export` — admin variant
(cookie-only via `requireAdmin()`; Bearer never elevates) with
a `userId` text field naming the target user. Same content-hash
idempotency scoped per target user.
The kick-off path uses a hand-rolled multipart boundary walker
(`src/lib/multipart/stream-to-disk.ts`) so a multi-gigabyte
`export.zip` upload never lands in V8 heap — Next.js's
`request.formData()` buffers the full body. Captured bytes
stream to disk while a SHA-256 hash accumulates in the same pass.
Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3, §5.4,
§5.5, §9.
The dashboard tile strip needs to render an "Letzter Wert vor Xd" caption on
tiles whose latest reading is older than a week so a metric the user hasn't
logged in a while keeps its tile visible (with an explicit how-old hint)
instead of disappearing from the strip.
- `/api/analytics` default path captures the freshest `measuredAt` per
`MeasurementType` in the existing per-type chunked walk and surfaces it as
`lastSeenByType[type] = { lastSeenAt, daysAgo } | null`. Types the user
never logged report `null`.
- `?slice=summaries` (slim slice) carries the same field — the DISTINCT ON
pass already orders by `measured_at DESC` to pick `latest.value`; adding
`measured_at` to the projection costs zero round-trips.
- Both responses stamp `Cache-Control: private, max-age=0, must-revalidate`
(the typed `NO_STORE_BUT_BFCACHE` constant) so back-forward navigation can
restore the dashboard from bfcache instead of paying a full reload. The
framework default for cookie-touching routes is `no-store, must-revalidate`,
which Chromium counts as a hard bfcache breaker.
- `AnalyticsRawPayload` gains an optional `lastSeenByType` field so call
sites read the shape with type-safety; consumers that don't care stay
unchanged.
Three new test cases pin the contract: default-path freshness map keyed on
the chunked reader, slim-slice freshness map keyed on the DISTINCT ON pass,
and the bfcache-friendly Cache-Control header on the wire for both slices.
Wires the streaming Apple Health export ingest into the existing
`pg-boss` worker process. New queue `apple-health-import` runs at
`localConcurrency: 1` because the parse loop is CPU-bound and a
second 1 GB import would race the first for RSS.
The handler:
* Resolves the mirror `ImportJob` row by `pgBossJobId` (with a
250 ms retry to absorb the kick-off-vs-worker race).
* Streams `export.xml` through `streamParseExportXml()`, surfacing
live progress onto the `ImportJob.progress` JSON column every
~1 000 records.
* Persists the terminal `ImportJobResult` envelope on `done` or a
truncated error reason on `failed`.
* Cleans up the upload + extracted XML on terminal state.
On worker startup, `reconcileOrphanImportJobs()` flips any row
stuck in `unpacking | parsing | upserting` to `failed` with
`interrupted_by_restart` so an operator-side restart never leaves
the polling endpoint hung mid-phase.
Three additive helpers:
* `import-apple-health-export.ts` — SAX-driven streaming parser that
folds `<Record>`, `<Workout>`, `<Correlation>`, `<ClinicalRecord>`
into the existing `Measurement` / `Workout` row shapes. Cumulative
HK types collapse into one `stats:<HKType>:<YYYY-MM-DD>` row per
user-local day; spot rows survive verbatim keyed by a stable
`sample:<sha256>` external id when no `HKMetadataKeyExternalUUID`
metadata entry is present.
* `hk-workout-activity-type-map.ts` — `HKWorkoutActivityType*` to
`WorkoutSportType` lookup table covering Apple's iOS 18 SDK
roster. Unknown identifiers fall through to `"other"` and increment
a `workouts.unknownActivityType` counter.
* `unzip-export-xml.ts` — hand-rolled ZIP central-directory walker
+ `inflateRawSync` extractor for `apple_health_export/export.xml`.
Supports Zip64 so multi-year exports past the 4 GB barrier still
parse. No new archive dependency.
Live progress streams through an `onProgress(ImportJobProgress)`
hook that fires every 1 000 records parsed; the worker persists the
snapshot onto `ImportJob.progress` for the polling endpoint to read.
Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
Add `src/lib/http/cache-headers.ts` exporting two presets and one
applier. `NO_STORE_BUT_BFCACHE` (`private, max-age=0, must-revalidate`)
replaces the framework's stock `no-store, must-revalidate` on
authenticated page responses; Chromium treats the latter as a hard
bfcache breaker, while the former keeps shared caches out, forces
revalidation on every navigation, and stays bfcache-eligible.
`SHORT_LIVED_PUBLIC` covers static-ish one-hour public payloads.
`applyAuthedHeaders(res)` stamps the bfcache-friendly directive onto
a Response in-place for route handlers that opt into the same posture.
Also add `scripts/print-bundle-report.mjs` (wired earlier as the
`bundle-report` npm script). Turbopack dropped the webpack "First
Load JS" summary line from `next build`; the script reads back the
`.next/analyze/client.json` payload that `@next/bundle-analyzer`
writes when `pnpm analyze` runs and prints a sorted table of the
largest client chunks (parsed, gzip, stat sizes plus totals). Output
restores an at-a-glance signal for client-bundle regressions on
release rounds without re-running Lighthouse.
v1.4.33 shipped a defensive fallthrough in `getColor` that detected
the unambiguous "every taken dose landed in veryLate" pattern and
recoloured those cells via the rate-based palette. With the
classifier hardening landed in `compliance.ts` the pattern can no
longer arise from a UTC offset between `takenAt` and the schedule
window, so the workaround is dead code. Replace the long
`looksClassifierBug` block with a single comment pointing at the
root fix and let the timing-based palette run unconditionally when
timing data is available.
The medication compliance route and the achievement perfect-day
heuristic both bucket the classifier output into `on_time` / `late` /
`very_late`. After the classifier started emitting `early`, both
paths would have silently dropped those doses into `very_late`
(via the trailing `else`). Treat `early` as compliant alongside
`on_time` so the heatmap stays green and the perfect-day streak
survives a proactive logger. The compliance route also surfaces a
dedicated `early` count on `DailyComplianceEntry` for any future
consumer that wants to break it out from `on_time`.
Updates the cadence-pairing docstring to reflect the widened grace.
The classifier flushed any intake outside the 1h grace into
`very_late`, so a stored UTC offset between `takenAt` and the
schedule window (e.g. a 06:00 UTC log against an 08:00 UTC window)
flagged a fully-compliant day as orange. Widen the pre-window grace
to 3h, introduce a dedicated `early` bucket for doses logged inside
that 3h pre-window band, and extend the on-time tolerance after
windowEnd to 3h as well. The configurable `lateMinutes` option now
sizes the `late` band past the 3h tail; doses beyond that tail still
fall into `very_late`.
Tests rebuild the boundary matrix with a parameterised offset suite
covering -3.5h .. +6h around the window, exercises overnight
schedules with an `early` case, and pins the `lateMinutes` knob.
Adds a Next.js Route Handler at `/.well-known/apple-app-site-association`
that answers 200 with `application/json` on every host that fronts the
main app — both `healthlog.bombeck.io` (maintainer prod) and
`demo.healthlog.dev` (demo) share this handler since they ride the same
standalone runtime. The body advertises 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 same ceremony that today exists at `/api/auth/passkey/*` on both
domains.
The `applinks.details` array stays empty for now; Universal Links are
out of scope for the first cut and pick up later once the iOS side opts
in to deep-linking specific routes.
Pairs with the proxy `/.well-known/` allowlist entry — without that
bypass the auth gate would 307 Apple's CDN fetch to `/auth/login`
before the handler ran.
Adds the `/.well-known/` prefix to the proxy's `PUBLIC_PATHS` allowlist so
RFC 8615 discovery endpoints reach a 200 response before the auth gate
runs. Apple in particular reads
`/.well-known/apple-app-site-association` without credentials to wire Web
Credentials (passkey sharing) and Universal Links to the iOS bundle, and
the previous 307 redirect to `/auth/login` broke that fetch on every host
the app serves (`healthlog.bombeck.io` and `demo.healthlog.dev` both).
The trailing-slash entry future-proofs the namespace for `/security.txt`,
`/openid-configuration`, and the other well-known endpoints the IETF
registry covers — adding a new file under `src/app/.well-known/` won't
need a second proxy edit.
A regression test pins the allowlist in place: unauthenticated requests
for the AASA file (and a synthetic `/.well-known/security.txt` peer) must
answer 200 with no location header, while a protected page like
`/insights` still 307s to the auth gate.
The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.
The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.
This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.
The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.
Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
The page now resolves CorrelationRow and TrendsRow through dynamic()
imports rather than top-level `from "..."` statements; the static-source
scan accepts either spelling so a future revert to eager imports still
flags the load-bearing module path.
The dedicated card H2 was retired in the previous "drop AI prefix"
pass; the `settings.kiInsights` translation key it consumed has no
remaining call-site in source. Drop it from de / en / es / fr / it /
pl. Also update the message-thread test to the new "Insights
provider" copy.
`no-scrollbar` already suppresses the painted scrollbar so the strip
doesn't look like every settings card has an overflow problem. The
`snap-x snap-mandatory` layer on the strip plus `snap-start` on each
chip means a swipe-flick lands on the next chip's leading edge instead
of the in-between dead zone — the auto-scroll-into-view effect on
mount stays the canonical positioner for the active chip; snap is the
polish layer for manual flicks.
Eleven sections on the settings sidebar was one over the comfort
line. About is three small cards (identity, links, update check) that
collectively get read once or twice a year and didn't earn a top-level
slot. Hide it from the in-shell nav and surface it as "About HealthLog"
in the sidebar user-card dropdown (mobile dropdown mirrors). The
`/settings/about` route stays alive so direct links, the public
`/about` permalink, and the dropdown entry continue to resolve.
The privacy + about pages constrain their main column to `max-w-3xl`
(768 px) while the rest of the app uses `max-w-screen-xl` (1280 px).
That's a deliberate readability choice -- long-form legal text is
easier to read at 70-80 chars per line -- but the convention wasn't
documented inline, so a future contributor could see the deviation
from the shell width as a stale token and "fix" it.
Inline rationale comment on both `<main>` blocks per the audit
finding §3.4. No layout change.
Lighthouse v1.4.33 a11y pass flagged "heading elements not in
sequentially descending order" (score 91/94). Three offenders:
- `<TherapyTimeline>` (insights/medikamente sub-page): sr-only
drug-name heading was an h4, but the parent SubPageShell ships
the only h1 and CardTitle is a <div>. Promoted to h2 so the
outline stays h1 -> h2 without skipping levels.
- `<FeedbackInboxDetailSheet>`: metadata + screenshot section
headings rendered as h4 inside a ResponsiveSheet whose dialog
title is h2 (Radix default). Demoted to h3 to stay sequential
beneath the sheet title.
- `<RemindersSection>`: results heading was h4 inside the admin
SectionFrame's h1. Promoted to h2.
Coach panel headings (IW5 surface) audited but not changed.
The sidebar has exactly one nav group, and the first entry is already
"Dashboard" pointing to `/`, so the "HOME / Dashboard" pairing used to
read as if there were two separate destinations for the homepage. Drop
the group label and retire the `nav.home` locale key in every bundle.
The collapse toggle keeps its position at the top-right of the strip.
Lighthouse v1.4.33 flagged ~463 KiB of unused JS on first /insights
paint plus ~211 KiB of bundled JS as unminified. Two coordinated
moves close the gap without touching the Coach panel internals or
the dashboard tree.
next.config.ts
- `compiler.removeConsole` with `exclude: ["error", "warn"]` strips
the chart-wiring + Coach SSE breadcrumb literals from the prod
bundle. Error + warn paths still flow through to GlitchTip.
- `headers()` advertises `Permissions-Policy: unload=()` so Chromium
admits the page to the back/forward cache when navigating away. We
carry no `beforeunload` / `unload` listeners in-tree so the hint is
honest.
src/app/insights/page.tsx
- `<DailyBriefing>`, `<CorrelationRow>`, `<TrendsRow>` move behind
`next/dynamic({ ssr: false, loading: <Skeleton /> })`. All three
paint below the hero on every viewport and were eagerly imported
before. Each loader skeleton matches the dimensions of the resolved
block so layout doesn't shift.
- `<HeroStrip>` stays an eager import — the greeting + health-score
badge are the visible-first-paint surface.
Marc-Voice rule — the product never names itself "KI" or "AI" in
copy. Settings -> "KI-Auswertungen" becomes "Auswertungen" / "Insights",
the inner "KI-Insights" card heading collapses into the parent section
heading, "Aktiver KI-Provider" becomes "Aktiver Provider", and the
admin "AI Quality" view becomes "Insights Quality".
Sweep across de / en / es / fr / it / pl. Provider product names
(Anthropic, Claude, ChatGPT, OpenAI) are unchanged — those are
proper nouns, not the "AI" label.
Lighthouse v1.4.33 a11y run flagged "links without discernible name"
(score 91/94). The medications page renders five icon-only buttons
(reset-form dropdown, three copy actions, status dropdown) without
an aria-label, so the SR announcement collapses to the role name.
This patch reuses existing common.* translation keys (no new strings
added; messages/*.json off-limits for IW9):
- the reset / status DropdownMenu triggers get `common.moreOptions`,
- the three copy icon-buttons get `common.copy`.
Icon-only buttons in `src/components/settings/**` (IW4),
`src/components/insights/coach-panel/**` (IW5), and the bottom-nav
already carry aria-labels per earlier polish rounds.
Lighthouse v1.4.33 a11y run flagged "ARIA progressbar elements missing
accessible names" (score 91/94). The Radix Progress primitive renders
a `role="progressbar"` host element but doesn't synthesise an
`aria-label` from context -- the consumer must supply one. Reusing
the on-screen label keys keeps the SR announcement matched to the
visible caption.
- src/app/insights/medikamente/page.tsx: compliance7/compliance30
- src/app/achievements/page.tsx: criterion meter + summary
earned-points + next-goal progress
- src/components/medications/medication-card.tsx: compliance7/30
- src/components/medications/glp1-medication-card.tsx: same
`role="progressbar"` divs in `health-score-card.tsx`,
`getting-started-checklist.tsx`, and `OnboardingShell.tsx` already
carry aria-labels -- verified, no change needed.
Refs Lighthouse-derived punch list (v1.4.33 perf audit).
The Coach launch FAB sits fixed at bottom-right on mobile viewports
and used to overlay any Recharts tooltip painted near the chart's
lower-right corner. Tapping a data point on /insights/blutdruck
opened the tooltip but the FAB covered the value readout, so the
user couldn't read it.
Add a singleton MutationObserver that watches every
.recharts-tooltip-wrapper in the DOM for inline visibility flips
(Recharts toggles `style.visibility: visible` when the tooltip
should paint, and clears it otherwise). A useSyncExternalStore-
backed hook surfaces the live boolean to the FAB, which fades to
opacity-0 + pointer-events-none + aria-hidden while a tooltip is
active. When the user releases / scrolls away and Recharts clears
the wrapper the FAB fades back in.
The observer is module-scoped: it attaches on the first subscriber
and disconnects on the last unmount, so it costs nothing on routes
that don't mount the FAB. SSR snapshot is always false so the
initial server-rendered FAB stays fully interactive before
hydration.
End-to-end auto-hide behaviour belongs to the Playwright mobile
suite (chart hover/long-press → FAB fades); the unit tests pin the
SSR-safe contract and the FAB markup so the regression cannot
silently revert.
Lighthouse v1.4.33 run flagged a 0.186 CLS attached to a
`div.space-y-2 > Button > Loader-Circle` element. Root cause: the
two heavy submit buttons in `/bugreport` and the medications import
dialog drew `{loading && <Loader2/>}<Icon/>` -- rendering BOTH icons
during the in-flight request and growing the button by ~24 px. The
fix is to swap (not append) the leading icon, so Loader2 occupies
the same 16x16 slot the static icon would.
- src/app/bugreport/page.tsx: ternary swap on `loading`, pass
`aria-busy` for screen-reader narration.
- src/app/medications/page.tsx (Import dialog): same shape.
- src/components/ui/button.tsx: document the loading-state contract
on the primitive so future contributors apply the swap convention
by default. (Callsites inside `src/components/settings/**` are
owned by IW4 -- left untouched, the doc-comment makes the convention
visible from the primitive.)
Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.
Locale parity across de / en / es / fr / it / pl.
Pre-v1.4.33 the shadcn Card defaults shipped `py-6` + `px-6` (uniform
24 px every breakpoint) while the dashboard tile-strip + chart cards
used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
`/insights` the correlation cards + daily-briefing read as denser
than the chart cards next to them; the visual reads as two card
languages on the same page.
This patch lifts the Card primitive (and its CardHeader / CardContent
/ CardFooter siblings) onto the same `p-4 md:p-6` token. Existing
consumers that paint their own explicit padding continue to win
through the `cn(...)` merge. Tests across `src/components/ui` /
`src/components/insights/__tests__` / `src/components/charts/__tests__`
(400 tests) still green.
Refs .planning/round-v1433-audit-polish.md §4.1 + Win 4.
Pre-v1.4.33 the insights mother page AND `<SubPageShell>` both fired
their own `requestAnimationFrame -> window.scrollTo({top:0})` on mount.
On slow hydrates the two callbacks landed ~16 ms apart and the second
one re-snapped the viewport once the chart skeleton inflated -- a
visible double-snap the maintainer flagged as "viewport jumps on click".
- src/hooks/use-scroll-reset-on-route.ts: new hook owns the RAF +
scrollTo dance, behaviour:"auto" so `prefers-reduced-motion` users
get the same instant landing everyone else does.
- src/components/insights/sub-page-shell.tsx: drop the inline effect,
call the hook. The opt-in `focusOnMount` ref-focus stays as its own
separate effect (different opt-in surface).
- src/app/insights/page.tsx: drop the duplicate effect, call the hook.
Refs .planning/round-v1433-audit-polish.md §2.1 + Win 3.
The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.
Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.
Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
Pre-v1.4.33 the dashboard shell capped at max-w-[76.8rem] (1228 px)
while settings/admin shells used max-w-screen-xl (1280 px) and the
bugreport route capped at max-w-6xl (1152 px). Route switches between
those surfaces produced a 52-128 px lateral shift on a 1440 px laptop
which the user perceives as the page "wobbling".
- src/components/layout/auth-shell.tsx: max-w-[76.8rem] -> max-w-screen-xl
- src/app/bugreport/page.tsx: drop the inner max-w-6xl cap so the shell
alone owns the content frame.
Settings/admin shells already use max-w-screen-xl; no change needed.
Refs .planning/round-v1433-audit-polish.md §4.2 + Win 1.
Every Settings card paints the same shape — icon + title + optional
status surface + optional muted description — but the codebase
carried five different `flex-*` permutations across
`account-section.tsx`, `integrations-section.tsx`,
`notification-status-card.tsx`, the three notification channel
cards, `api-section.tsx`, and `advanced-section.tsx`. The mechanical
drift table in `.planning/round-v1433-audit-settings.md` §2.5
itemised the spread.
Consolidate the contract into `_card-header.tsx`: icon left, title
right of it, status slot lives top-right and falls below the title
on `<sm`, description sits muted below the title row. Call sites
keep ownership of their body forms and action footers.
First three call sites switched: `telegram-card.tsx`,
`ntfy-card.tsx`, `web-push-card.tsx`. The remaining cards
(`account-section.tsx`, `integrations-section.tsx`, `api-section.tsx`,
`advanced-section.tsx`, `notification-status-card.tsx`) are wired
in a follow-up — the primitive is now available without forcing
every consumer to migrate in one commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Centralise the trend-arrow + 7-day-delta + comparison-overlay colour
through a single `getTrendSentiment(change, sentiment)` helper so the
three elements never paint disagreeing signals on the same tile
(F5 audit fix). Arrow direction now follows the same signal that
drives the 7-day delta value.
- Collapse the dashboard tile strip from `flex overflow-x-auto` at <sm
+ grid above into a single responsive `repeat(auto-fit,
minmax(min(100%, 11rem), 1fr))` track. Phones wrap to two rows
instead of side-scrolling; 1440×900 picks 6 columns so the headline
digits stop truncating to `8…` / `1.`.
- Hold the tile callout slot at `min-h-[18px]` on every breakpoint so
a comparison overlay on one tile no longer pushes its callout-less
neighbours 18 px taller.
- Gate the BD-Zielbereich tile so a literal "0,0 %" placeholder no
longer renders when every window (7d / 30d / all-time) reports
zero. The tile shows once any window crosses zero; otherwise the
`/targets` Blutdruck card carries the deeper analysis.
- Restore proportional sizing on the dashboard Hinzufügen button:
`size="default"` with `min-h-11 sm:min-h-9` keeps WCAG 2.5.5 on
mobile (44 px) and tightens to a desktop-friendly 36 px above sm.
- Surface `allTimeCount` + `lastSeenAt` per metric on
`/api/dashboard/summary` so the iOS client can keep a tile visible
during a logging gap and render a `Letzter Wert vor Xd` caption.
Optional cards (glucose / sleep / steps / body fat) widen the emit
gate from "7-day window has data" to "ever logged". Added the
matching `staleDays` prop to `<TrendCard>` and `dashboard.staleHint`
i18n keys across all six locales.
Five small polish items from the v1.4.33 audit, plus the
maintainer's scroll-into-view item:
* Export-section card padding promoted from `p-5` to `p-6` so the
Export grid rhythm matches every other Settings card.
* Passkey list flips from a desktop table to a mobile card list at
`lg` instead of `md`. iPad portrait (768 px) used to sit on the
`md` inflection and flip-flop between the two layouts on
rotation; the card layout reads fine on iPad portrait, the table
needs ~620 px of column width to show the destructive delete
action without horizontal scroll.
* `<SettingsShell>` mobile chip strip now scrolls its active chip
into view on route change (`block: "nearest", inline: "center",
behavior: "smooth"`). On a 393 CSS px viewport the 11-chip strip
pushes the rightmost four chips below the fold; without the
scroll-into-view, tapping one of those chips left the strip
pinned to the leftmost chip on the new page, which read as a
broken nav. Smooth scroll honours the user's
`prefers-reduced-motion` via the browser's own auto-detection.
* F17 — `thresholds-editor-section` toggle label anchors on the
*action* it performs ("Eigene Werte" / "Custom range") instead
of mirroring the current state ("Auto" / "Überschrieben"). The
old label read as "Auto is off, why are there no inputs?" when
the toggle was off and inputs were hidden; the new label
unambiguously announces "flip me to enter a custom range". The
`thresholds.sourceOverride` badge still surfaces when the
override is the active source. Adds
`thresholds.overrideToggleLabel`.
Locale parity across de, en, es, fr, it, pl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.
Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):
1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
`MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.
2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
`(type, value)` pair per type so the slim shape can populate
`latest` (the value at MAX(measured_at), not MAX(value)).
Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.
The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.
Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was
computed inline in useMemo(), which the react-hooks/purity lint rule
rejects: Math.random() is impure and the render body must be
idempotent. The original useMemo() happened to be stable in practice
(empty dep array) but the rule fires regardless because the
expression inside the memo factory is impure.
Move the draw into useEffect(() => {}, []) and stash the result in a
useRef so every reportWebVitals callback in the same navigation reads
the same decision. SSR seeds the ref to null, so a pre-hydration
beacon (which would have no rate-limit relief value) is dropped; the
first post-hydration callback finds the ref populated and decides
whether to POST. Bundle and runtime cost unchanged — same one draw
per mount, same all-or-none per page-load. ESLint clean.
Dead-code sweep flagged by the v1.4.33 menu audit.
* AssistantDisabledNotice (src/components/insights/assistant-disabled-notice.tsx)
was introduced in v1.4.31 for the operator-disabled assistant surface
but never wired into a render site. The v1.4.28 retirement of the
weekly-report + advisor card removed the planned host components.
Delete the component, its SSR test, and the four locale strings it
consumed (insights.briefingDisabledByOperator,
insights.statusDisabledByOperator, insights.correlationsDisabledByOperator,
insights.coach.disabledByOperator) from all six locale files. The
AssistantDisabledError thrown by requireAssistantSurface() and the
assistant.disabled.<surface> errorCode envelope stay — iOS clients
read the errorCode to render an inline notice.
* settings.sections.placeholder.coming_soon was an "until extraction
lands" string for <SectionPlaceholder>. Every slug in
SETTINGS_SECTION_SLUGS has had a matching component since v1.4.22
so the placeholder body is unreachable through the type system. The
defensive component itself stays as a guard, but inherits the
section's own description for the EmptyState body, retiring the
dedicated locale key from all six locale files.
Locale-integrity contract holds (parity check green across de, en, es,
fr, it, pl). Comments in api-handler.ts and feature-flags/index.ts
that referenced <AssistantDisabledNotice> are updated to describe
the surface generically.
Refs: .planning/round-v1433-audit-menu.md §6
Telegram, ntfy, and Web Push cards used to paint their own
"Configured · Disabled / Active" badge pair in the top-right
header. The `<NotificationStatusCard>` rendered above the same
cards already shows a live channel pill ("Active · 12 min ago" /
"Auto-disabled — web_push_410_gone"), so the per-card badges were
the third (sometimes fourth) surface for the same state.
Drop the dual-Badge slot from all three cards. The section header
pill is the single source of truth, the per-card surface now
reads as identity + description + form, and the in-app vocabulary
no longer mixes "Konfiguriert", "Aktiv", and "Verbunden" for the
same fact.
Copy pass:
* Settings → Notifications description: "Live-Übersicht aller
konfigurierten Kanäle." (was a 13-word sentence repeating "Kanal"
three times).
* `notificationStatus.title` renames to "Zustellstatus" /
"Delivery status" so the page header and the first card don't
both anchor on "Kanal".
* `notificationStatus.description` compresses the "Auto-disabled"
definition.
* Telegram card title drops "-Benachrichtigungen" — the section is
Notifications, so saying it again is wasted shelf space.
* `telegramDescription` shortens to "Medikamenten-Erinnerungen
per Bot." / "Medication reminders via bot."
* `moodLogDescription` drops the second "moodLog" mention.
Notification-status SSR test updated to the new copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>