Replace the per-row rawMeasurements dump with DAY/WEEK/MONTH bucket
means read from measurement_rollups. The legacy shape blew past
Codex's 10 MB string limit on power-user accounts; the bucketed shape
stays O(types × buckets) regardless of row count.
Enforce a 5 MB ceiling on the serialised payload; the generate route
downgrades to the aggregated shape and annotates when the cap trips so
future regressions surface in logs.
The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.
Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.
Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
Each `trends-row-chart-slot` wrapper had `shrink-0` but no explicit
height. Recharts' ResponsiveContainer mounted with `width=-1
height=-1` and emitted a warning on every Insights cold mount; the
mood card visibly drifted taller than BP / weight as the chart
hydrated, contributing the bulk of the Lighthouse 0.46 CLS hit.
Pin the wrapper to 140 px (matches the chart-internal mini default).
Recharts now mounts at a known size, the three cards line up on the
same baseline from first paint, and the warning is gone. Test pins
the height on all three slots so a future refactor has to revisit
the row rhythm intentionally.
Removes the operator step that was implicit in the v1.4.35 foundation.
Self-hosters no longer need to run the backfill script after upgrade.
New `rollup-full-backfill` queue + handler folds one user per job at
serial concurrency. `enqueueBootTimeRollupBackfill` runs once at
`startReminderWorker` boot, discovers accounts with measurements but
zero rollup coverage, and enqueues one full-fold job per uncovered
user with a singleton key so fast restarts don't pile up duplicates.
Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.
Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
from the DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets.
A defensive parity check against the live aggregate's COUNT(*) falls
back to live SQL when divergence is detected: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.
Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.
tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.
Schema:
- new MeasurementRollup model with composite PK
(userId, type, granularity, bucketStart) and a descending
bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
- Prisma migration 0067_v1434_measurement_rollups is additive:
CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
- db-compat schema-bootstrap path mirrored.
Populator (src/lib/measurements/rollups.ts):
- recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
pg-boss (singleton-keyed).
- recomputeUserRollups: bounded full-range, used by Apple Health
import + backfill.
- ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
recompute. Error-swallowing.
- SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
unit (Postgres 42803 forbids binding the unit literal).
Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.
Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).
Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.
Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.
Read-swap follow-up:
- comprehensive-aggregator + analytics/route + summaries-slice
each call ensureUserRollupsFresh as a read side-effect (warms
the table). The response stays on the live-SQL aggregator so
byte-shape parity with v1.4.34.5 is preserved.
- Next commit lands the partial read-swap: count/min/max/mean
and dailyByType from buckets (math-identical compose), while
slope/r2/sd stay on indexed live aggregation. Together they
ship as v1.4.35.
Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
End-to-end summary of the eight-axis audit + the two consecutive
hotfix releases that landed the hotfix-ready findings: security,
UX/a11y, code quality, mobile-deep, docs depth, README + GitHub
discoverability, perf, and test coverage. Lists what shipped, what
got deferred to v1.5.x, the operator-action delta (one obsoleted),
and the quantitative deltas (24 commits, 2 releases, 55 net new
tests, 5 new user-facing docs).
Follow-on to v1.4.34.4: 24 new critical-path integration tests
(auth-password-change + admin-reset-password + withings-oauth +
passkey-register + cache-invalidation-coverage) and the textarea
iOS-zoom-on-focus fix across 5 sites. Integration suite goes from
197/50 to 222/55.
Five textareas in the app (Coach composer, bug-report description,
admin feedback note, medication-side-effects notes, medication-intake
JSON import) shipped `text-sm` (14 px) on every viewport. iOS Safari
triggers a viewport zoom-on-focus whenever an input renders text
below 16 px — so every tap on these surfaces yanked the page scale
and rebounded on blur. Switched each className to `text-base sm:text-sm`
so the mobile baseline clears 16 px while desktop keeps the compact
look the rest of the form chrome converged on.
No shared <Textarea> primitive exists yet — F-5's deferred work — so
the fix is per-call-site. Five files, one className change each, no
component re-shape.
Closes the highest-severity gaps from the v1.4.34.3 test-coverage
audit. Five new integration files, 24 new cases.
auth-password-change.test.ts (5 cases):
Pins the v1.4.34.3+ `destroyAllSessions` contract end-to-end.
Asserts that after a successful password change the user's
Session.deleteMany ran, ApiToken.revoked flipped to true, and
RefreshToken.revokedAt was set. Plus wrong-current-password (401)
and weak-new-password (422) negative paths. A second
concurrent-request that holds the old session cookie is rejected
by the next session-protected endpoint.
admin-reset-password.test.ts (5 cases):
Same three-transport revocation shape for the admin-driven
reset. Non-admin auth rejects with 403. Audit-log row carries
the action name `admin.user.reset-password` and the actor's
user-id.
withings-oauth.test.ts (5 cases):
GET /api/integrations/withings/connect returns the upstream
authorise URL with the right scopes + redirect URI. GET
/api/withings/callback exchanges code for tokens by stubbing
the Withings token endpoint; the user row's encrypted token
pair populates. POST /api/withings/disconnect clears them.
State-token replay (wrong cookie) rejects with 400. The
Set-Cookie header carries the URL-encoded form of the state
string — the assertion compares on the encoded value, not the
literal.
passkey-register.test.ts (4 cases):
/api/auth/passkey/register-options issues a fresh challenge
bound to the RP-ID. /api/auth/passkey/register-verify against a
stubbed @simplewebauthn/server verifier creates the Passkey
row. A tampered attestation rejects with 422 + a clear
errorCode. The challenge is single-use.
cache-invalidation-coverage.test.ts (5 cases):
Grids the v1.4.34 IW-G invalidation matrix across 5 write
surfaces: measurement POST evicts caches.analytics; mood-entry
POST evicts caches.moodAnalytics; medication POST evicts
caches.medications; dashboard-widgets PUT evicts
caches.dashboardWidgets; admin app-settings PATCH evicts
caches.bugreportStatus. The integration setup's `isolate: false`
parallel-DB-bleed quirk on app_settings was handled with a
`vi.importActual("@/lib/db-compat").ensureDbCompatibility()`
in beforeEach — pattern worth noting for any future test
touching that table.
Run: `pnpm test:integration` reports 222/222 passing across 55
files (was 197 in 50 files before this batch); tsc + lint clean.
Audit-driven hotfix bundle. CHANGELOG carries the full notes — this
commit stamps the version and the changelog header so the release
pipeline picks it up.
This is the sixth release deployed under v1.4.34.2's pull_policy:
always — the deploy mechanic should pick up the image without any
host-side retag fallback.
Three end-to-end integration walkthroughs closing the docs gaps the
v1.4.34.3 docs-depth audit flagged as critical user-facing surfaces.
Apple Health (docs/integrations/apple-health.md, 200 lines):
- Generating the iOS `export.zip` from the Health app.
- 1.5 GB upload cap, Zip64-aware streaming parser, content-hash
idempotency.
- Per-record / per-workout / per-correlation / per-clinical-record
coverage matrix grounded in the parser's source.
- Cumulative HK-type daily aggregation contract
(`stats:<HKType>:<YYYY-MM-DD>` external IDs).
- Live status-polling endpoint and the admin-import variant.
- Source-priority interaction with the existing Withings ladder.
Withings (docs/integrations/withings.md, 195 lines):
- Developer-portal app registration with the right OAuth scopes
(`user.metrics user.activity`) and redirect URI.
- Env-var layout (`WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`,
`WITHINGS_WEBHOOK_SECRET`).
- Why the webhook URL carries the secret as a path segment
(v1.4.25 W17a — reverse proxies log `query_string` by default;
path segments aren't logged, so the secret stays off the access
log).
- Sync coverage (weight, body composition, BP, sleep, activity,
SpO₂), reauth handling, troubleshooting.
AI providers (docs/integrations/ai-providers.md, 244 lines):
- All four `User.aiProvider` values (OPENAI, ANTHROPIC, LOCAL,
CHATGPT_OAUTH).
- Default models, BYOK vs admin-shared, the provider fallback chain.
- Local-endpoint setup with Ollama, LM Studio, and vLLM examples;
SSRF-guard rationale; ChatGPT device-code OAuth pointer.
- Privacy stance — local endpoints keep measurement context inside
your network; hosted endpoints send the snapshot to the provider.
The sticky header was h-16 with no safe-area handling. On notched
iPhones (~44 px status-bar inset) the iOS PWA painted the status bar
over the header — the HealthLog logo + auth controls clipped under
the system clock. Adding the inset as inline padding-top reserves the
overlay without touching the 64 px content baseline.
Inline `style` over a Tailwind class because Tailwind doesn't expose
`pt-[env(safe-area-inset-top)]` without arbitrary-value escaping and
the inline form is clearer in the diff.
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.
The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.
Mobile-deep:
- `viewport.themeColor` was pinned to `#282a36` (dark). The
Android URL bar + iOS PWA status bar stayed dark even when the
user was on the light theme. Replaced with the dual-media-query
form: `#ffffff` under `(prefers-color-scheme: light)`,
`#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
now matches the active palette.
- Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
the home-indicator safe area is ~34 px so the FAB collided with
the bottom-nav. Moved to
`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
floats above the inset-aware nav.
- Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
page the toast contrasted badly against the surface. Switched to
`theme="system"` so Sonner reads the resolved theme from the
root element (set pre-paint by the inline theme script in
layout.tsx) instead of pinning dark.
Self-hosting docs:
- `docs/self-hosting/getting-started.md` — step-by-step from
`git clone` to first measurement, covering the 3-secret env
setup (post-SESSION_SECRET drop), `docker compose up -d`, the
first-user-becomes-admin contract, and the next-step pointers.
- `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
Proxy Manager, Coolify, and bare-Nginx config examples. Closes
the README link at line 107 that previously punted to the docs
site.
Five hotfix-ready findings from the v1.4.34.3 code-quality audit.
SESSION_SECRET drop (F-3):
The env var was required by `docker-entrypoint.sh`, listed in
`.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the
scaling guide — but `grep -rn SESSION_SECRET src/` returned
nothing. Sessions are server-stored in `prisma.session` keyed by
the cookie value; the secret signed nothing. Every self-hoster
carried a ceremonial 64-char hex string that the app ignored.
Removed everywhere except the README (separate batch). Each fresh
install now generates 3 secrets, not 4.
toJson helper (F-9):
Seven sites cast Prisma JSON writes via `value as unknown as
Prisma.InputJsonValue`. Each call re-invented the same escape
hatch. Replaced with one helper in `src/lib/db.ts`:
`export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
Migrated all 7 call sites: `auth/me/doctor-report-prefs`,
`dashboard/chart-overlay-prefs`, `dashboard/widgets`,
`integrations/healthkit`, and 4 sites in
`apple-health-import-worker`. The helper has a one-line WHY
comment + a unit test pinning the cast shape stays stable.
db-compat docstring (F-10):
`src/lib/db-compat.ts` looks like accumulated backwards-compat
cruft on first read. It's actually the schema-bootstrap path that
runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without
`prisma migrate deploy`. Added a header docstring naming the role
and pointing at `prisma/migrations/` as the canonical history.
Passkey types (F-11):
`src/lib/auth/passkey.ts:103,169` used `response: any` with
per-line `eslint-disable`. Imported `RegistrationResponseJSON` and
`AuthenticationResponseJSON` from `@simplewebauthn/types` and
dropped the disables.
Zero-TODO CI gate (F-15):
The audit confirmed product code has zero TODO / FIXME / XXX /
HACK markers — genuinely rare and worth preserving. Added a tiny
GitHub Actions workflow at `.github/workflows/no-todo-markers.yml`
that fails the build on any future re-introduction. Test files
are excluded; product code only.
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.
i18n leaks (3 primitives):
- Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
so a German user heard 'Close, button' on every modal. Both lift
`useTranslations()` and read `common.close`.
- ChartSkeleton's sr-only loading announcement was English-only.
The component is already a client island; reads `charts.loadingLabel`
from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
- Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
was hardcoded. Routes through `t("nav.breadcrumb")` with a new
key in each locale.
Focus rings (mobile + tablet nav):
- Bottom-nav primary links + the More overflow trigger relied on a
`text-muted-foreground` → `text-primary` colour transition only;
keyboard / switch-control users saw no focus indicator at all.
Added `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2` matching
the v1.4.33 sidebar treatment.
- Mobile top-bar trigger had `focus:outline-none` without a
`focus-visible` replacement. Same treatment applied.
Tap-target floor (WCAG 2.5.5):
- Input / NativeSelect / Select / DateTimeInput were locked to
h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
mobile clears the 44 px floor while desktop keeps the 40 px norm
that the Button primitive already converged on in v1.4.33.
Admin chip-strip auto-scroll-into-view:
- The admin shell shipped 13 chips but lacked the
`scrollIntoView({inline: 'center'})` effect the settings shell
got in v1.4.33. Admins tapping a chip past the right edge landed
on the new page with the strip scrolled to the left and the
active chip off-screen. Copied the settings-shell pattern verbatim.
List-page subtitles on mobile:
- Measurements / Mood / Medications pages hid their descriptive
subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
the subtitle on mobile because it doubles as contextual help.
Dropped the hide rule; the H1 still anchors the visual.
Insights empty-state CTA vocabulary:
- `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
`TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
`MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
empty-state CTAs emitted those mismatched tokens and the dialog
silently failed to open. Replaced with a single source of truth
derived from `MEASUREMENT_TYPES` plus a normalisation map for
legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
New unit test walks every `?add=` token emitted under
`src/app/insights/**` and asserts it resolves to a real form type.
HSTS gains `; preload` so the domain becomes eligible for the
Chromium preload list. Withings CSP host moves from global to a
path-gated entry (mirrors the existing AI-host gating) — closes a
DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns
once per process when `TRUST_PROXY_HOPS` and the XFF chain length
disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so
future callers can route to a tighter universal rate-limit bucket
when the chain looks tampered with.
Service worker now resolves push-notification payload URLs against
`self.location.origin` and rejects off-origin redirects (falls back
to `/`). The German-only offline fallback HTML body was replaced
with a language-neutral icon + 'Offline' wordmark so EN / FR / ES /
IT / PL users don't see German on a cold offline boot. Manifest
gained `"scope": "/"`, `"id": "/?source=pwa"`, and
`"display_override": ["standalone"]` so future sub-path deploys
don't accidentally inherit root scope and install-prompt heuristics
anchor on a stable id.
+16 new test cases across two new files covering HSTS preload, CSP
gating on/off, the once-per-process warning, the tagged helper's 4
shape branches, SW same-origin guard, offline-locale-neutrality, and
manifest hygiene. Existing proxy/SW suites stay green.
README:
- Add a "How it compares" matrix against Withings web, Apple Health,
Oura, Garmin, and generic CSV. The audit found this section absent;
self-hosters arrive comparing alternatives and bounce when there's
no head-to-head.
- Promote Apple Health import + AI Coach as standalone Key-Features
bullets. Apple Health was the v1.4.34 banner feature with zero
grep matches in the README until now; the audit ranked it the
single highest-intent search keyword for ex-iOS users. AI Coach
+ Insights moved out from under "Multi-Provider AI Insights" into
a dedicated bullet that names the Coach, Daily Briefing, Weekly
Report, and Health Score surfaces.
- Status block under "What it is" so visitors see release cadence
+ current focus (native iOS client) without trawling the changelog.
- Badge row: drop "PRs welcome" + duplicate Self-Hosted, add a live
"Latest release" badge + a GHCR multi-arch badge so the landing
page surfaces version + container distribution at a glance.
- Tagline tightened to lead with "self-hosted health tracker" + the
two integration anchors (Withings + Apple Health) — both phrases
are search-keyword fronts the GitHub search index picks up.
- Drop the dead SESSION_SECRET env entry from Quick Start (3
secrets, not 4) and the Environment Variables table — the env
var was documented as required but never read by code (per the
code-quality audit). Companion env-removal in docker-compose /
.env.example / entrypoint ships in a parallel batch.
- Add a one-line OpenAPI pointer above the API-Reference table so
integrators discover the codegen source.
Layout / OG metadata:
- openGraph + twitter cards now carry images, a higher-intent
description, and a summary_large_image card. Uses the existing
logo-readme.png as the OG asset; replace with a 1200x630 dashboard
capture when one ships.
GitHub repo metadata (via gh CLI, no git diff):
- description tightened from 290 chars to 156 chars, keyword-front.
- homepage flipped from a personal tenant to demo.healthlog.dev so
sidebar clicks land on a working demo instead of a login wall.
- Topics: dropped 6 generic/PII-adjacent entries (glp-1, mounjaro,
tracking, health, dashboard, bloodpressure), added 6 high-intent
ones (apple-health-import, withings-alternative, glucose-tracker,
mood-tracker, ai-insights, personal-dashboard).
Planning artifacts:
- 7 audit reports drop into .planning/round-audit-*.md covering
mobile-security, UX, code-quality, README/discoverability,
perf, mobile-deep (pending), test-coverage, docs-depth. These
are the working notes for the v1.4.34.4 audit-driven release.
Two findings from the v1.4.34.3 security audit, both with the same
shape — every authenticated surface a user owns should rotate when a
credential-rotation path fires, and unfamiliar discovery sub-paths
must not inherit "no auth" status by default.
destroyAllSessions:
Previously only ran `prisma.session.deleteMany`, leaving every
long-lived `ApiToken` row (Bearer credentials issued via
/settings/api-tokens) and every native-client `RefreshToken`
intact. A stolen iOS Bearer kept working after the user rotated
their password or after an admin reset; only the browser cookie
flipped. Wraps the cleanup in a $transaction that flips
`apiToken.revoked = true` and `refreshToken.revokedAt = now()` in
the same atomic block so the audit trail survives. Both lookups at
`src/lib/auth/api-token.ts` and `src/lib/auth/refresh-token.ts`
already filter on these columns; no second code path needed.
Same destroyAllSessions call already runs from:
- POST /api/auth/password (user-initiated change)
- POST /api/admin/users/[id]/reset-password (admin reset)
- DELETE /api/settings/account (account deletion)
All three paths now wipe every transport in one shot.
Well-known proxy bypass:
`src/proxy.ts` PUBLIC_PATHS carried "/.well-known/" with a prefix
match — a future route at `/.well-known/foo` would inherit "no
auth" status silently. Tighten to an exact-match Set
(WELL_KNOWN_PUBLIC_PATHS) seeded with the one path we actually
serve (`apple-app-site-association`). New routes must be added
explicitly.
Also drops the stale `/api/auth/codex/callback` PUBLIC_PATHS
entry — that route doesn't exist in the codebase (the Codex
OAuth flow uses `/api/auth/codex/{device-poll,device-start,
disconnect}` instead).
Tests:
- New destroyAllSessions assertion in src/lib/auth/__tests__/
session.test.ts pins the three updateMany/deleteMany call
shapes inside a single $transaction.
- Existing proxy well-known + privacy + onboarding redirect
suites stay green.
Per maintainer directive — the v1.4.34 IW-B "Frag den Coach" sparkles
button sat next to the "Hinzufügen" dropdown on the dashboard hero and
read as visual noise rather than a useful surface. Remove it; the
Coach drawer remains reachable from the /insights tree (its original
home), and the auth-shell `<CoachLaunchProvider>` + drawer mount stay
intact so any future page that wants its own opt-in CTA can call
`askCoach()` without churn.
The hook usages this leaves orphaned in `src/app/page.tsx`:
- `useCoachLaunch` (only consumer was the CTA)
- `useFeatureFlags` (only gate was `featureFlags.coach`)
- `Sparkles` icon import
All drop out cleanly.
The v1.4.34.1 image published to GHCR successfully but never reached
production because Coolify's `docker compose up` kept reusing the
cached `:latest` digest on the apps-01 host. The default Docker pull
policy ("only pull if missing") skipped the registry round-trip and
restarted the container with the prior v1.4.34 image.
Declare `pull_policy: always` on the `app` service so compose-up
re-checks the registry digest on every `up` regardless of the local
cache. Bundle-release because the v1.4.34.1 work ships through the
same `:latest` tag — this is the deploy mechanic that makes v1.4.34.1
actually land.
Hotfix on top of v1.4.34 bundling the chart-card dimension fix and the
Insights cold-mount perf rewrite. CHANGELOG carries the full notes; this
commit just stamps the version + changelog header so the release
pipeline picks it up.
Backfills the planning trail for v1.4.34: IW-C / IW-D / IW-G work-item
reports, the production slowness HAR investigation, the AASA rollout
trace from v1.4.33, and three pieces of research that informed the
follow-on perf work (XML import, carryover scope, cache aggregation).
The cache-aggregation research (research/v1434-r-cache-aggregation.md)
designed the per-route LRU pattern that IW-G shipped and is the
direct ancestor of the comprehensive-route rewrite this release lands.
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.
Sub-wave E records the close-out work for v1.4.34: e2e flake fixes
in onboarding-flicker + mobile-viewport, AASA verification across
healthlog.dev / healthlog.bombeck.io / demo.healthlog.dev plus
Apple's CDN ingestion endpoints, and the web-freeze marker that
lands on the CHANGELOG, prisma/schema.prisma, and the v1.5
strategic-plan decision log.
A caveat: a concurrent IW-G commit swept the staged web-freeze
edits into its own commit before this sub-wave's atomic commit ran.
The content landed correctly (verified with git show a5dd8e1d); the
commit message attribution is the only thing out of place. The
report documents the situation so the release-prep run can read
the actual state from disk.
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.
Three pre-existing flakes in the v1.4.33 suite were carrying through to
v1.4.34 as 113 passed / 3 failed. Both specs needed tighter waits and
selectors:
- onboarding-flicker (desktop + mobile): replaced the 12-sample
50-ms poll-loop with a single auto-retrying `toBeHidden({ timeout:
700 })` assertion. The poll-loop had a 1-2 ms race window where the
analytics-pending shell could paint before `useAuth().user` resolved
`onboardingCompletedAt`. The auto-retrying assertion re-evaluates
every animation frame and preserves the intent ("user never sees
the card") while collapsing the window.
- mobile-viewport (chromium-mobile): dropped `nav a[href]` from the
CTA-touch-target sweep (bottom-nav owns its own WCAG enforcement),
gated the 44-px floor on a `matchMedia('(min-width: 640px)').matches
=== false` evaluate so the desktop breakpoint never hits the
assertion, and prefer the `aria-label` over raw `innerText` when
surfacing failure labels.
Captures the analytics lastSeenByType surface, the bfcache-friendly
Cache-Control directive, the dashboard tile-strip staleDays wiring,
the CoachLaunchProvider hoist, and the dashboard hero CTA — three
commits on develop with quality gates passing across 4205 tests.
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.
Two key additions land in every locale (de, en, es, fr, it, pl):
- `insights.tabStrip.vitalsParent.{label,header}` — the parent pill
text and popover header for the collapsed wave-A vital pills.
- `settings.sections.thresholds.{title,description}` — renamed to
"Targets & Sources" / "Zielwerte & Quellen" / "Objetivos y fuentes"
/ "Objectifs et sources" / "Obiettivi e fonti" / "Cele i źródła"
(and matching descriptions).
- `settings.sections.sources.redirectNotice` — copy reserved for the
back-compat redirect surface; reads "Sources are now part of
'Targets & Sources'" in each locale.
The existing `settings.sections.sources.*` block stays in the
locale files because `<SourcesSection>` (now embedded inside the
Targets & Sources page) still resolves card-level labels through
those keys.
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 + §8.
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).
Round close-out for the v1.4.34 Apple Health export.zip import work-
item. Records the seven atomic commits (deps → schema → parser →
worker → endpoints → tests → changelog), the parser perf numbers,
the carry-overs (HKClinicalRecord, ECG waveforms, workout routes,
ActivitySummary, <Me>), and the quality-gate evidence.