Swaps `/api/medications/intake?scope=compliance` onto the rollup
reader behind a coverage probe — a user with intake events but no
rollup rows still gets a correct response from the legacy live
aggregator and triggers the boot backfill in the background so the
next request lands on the rollup path. Every write site that mutates
`MedicationIntakeEvent` (POST + PUT + DELETE + per-medication import,
bulk batch, telegram callbacks, external API ingest, admin restore)
now fires `recomputeMedicationComplianceForEvent` for the affected
user-day. Bulk + import collapse the per-row hooks to one recompute
per distinct `(medicationId, dayKey)` so a 500-row ingest still costs
a bounded fold. Purge wipes the rollup partition alongside the
intake-event deleteMany. Eliminates the 3.2 s cold mount the
v1.4.38 perf analysis attributed to the unbounded event findMany.
Materialises the persistent medication-compliance rollup tier behind
`MedicationComplianceRollup`. Per-`(userId, medicationId, day)` row
where `day` is `YYYY-MM-DD` anchored to the user's IANA timezone.
`recomputeMedicationComplianceForDay` upserts the row from the events
inside the local-day UTC window; `readMedicationCompliance` returns a
zero-filled trailing-window bucket array matching the legacy
`buildComplianceBuckets` shape so consumer swaps are byte-stable.
`hasMedicationComplianceCoverage` powers the route's coverage probe
and `recomputeUserMedicationCompliance` is the full-fold the worker
boot backfill calls.
Unit tests pin the per-write hook contract (DAY upsert + WEEK/MONTH/
YEAR enqueue + delete-on-empty + idempotency under re-run), the
warm-up helper (no-op on fresh / dedup on concurrent / annotate on
populator failure), and the boot-time discovery enqueue. Route
tests pin the parity contract: identical entries + summary shape
between the rollup fast path and the live-fallback walk, plus the
LRU warm-read invariant.
Registers mood-rollup-recompute (per-bucket WEEK/MONTH/YEAR fold)
and mood-rollup-full-backfill (boot discovery) onto pg-boss. The
worker boot now fires a discovery query that enqueues one full-fold
job per user with mood entries but zero mood_entry_rollups rows —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. Legacy accounts
pre-dating the v1.4.39 deploy auto-converge on the persistent tier
without operator action.
The /api/mood/analytics cold mount walked every MoodEntry the user
had ever written into Node only to bucket-average in JS. Reroutes
the read through readMoodDayRollups which returns one row per
calendar day from the persisted tier, and wires the per-write hook
into every mood mutation site so the rollup stays current:
- POST/PUT/DELETE /api/mood-entries — inline recompute
- POST /api/mood-entries/bulk — collapsed to per-day fan-out
- POST /api/integrations/moodlog/webhook — inline recompute
- syncMoodLogEntries — bounded re-fold after batch sync
- DELETE /api/settings/moodlog + DELETE /api/settings/data —
drop the user's rollup partition alongside the entries
- admin restore + import — re-fold after the bulk write
Coverage-fallback mirrors summaries-slice: when the rollup count is
zero but raw mood entries exist (legacy account before the boot
backfill catches up) the route falls through to the legacy live walk
once and the warm-up helper fires asynchronously so the next request
lands on the fast path.
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.
- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
perf-verify can prove the cap is in effect on a live request.
Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).
Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.
Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.
Adds `computeLongWindowSummary(userId, type, windowDays)` — the
ready-to-use entry point for the v1.5 multi-year trend card and the
Coach drawer's "history" tile. Routes the requested window through
the largest granularity that still resolves it (90 d \u2192 DAY,
365 d \u2192 MONTH, 1095 d \u2192 YEAR) via
`readBestGranularityRollups`, then composes `count / min / max /
mean / sum` linearly from the bucket rows. SD / slope / r2 are
intentionally omitted because they do not compose across coarser
buckets.
The existing `computeSummariesSlice` shape is untouched — its
windowed columns cap at 90 d so the live SQL path is already
DAY-tier optimal. Wiring the all-time aggregate through YEAR
buckets would risk staleness on multi-year data because the
WEEK / MONTH / YEAR tiers update asynchronously via pg-boss while
DAY is kept fresh by the sync write-hook. Deferring that swap to
v1.5 (covered in the W-WMY phase report).
18 unit pins covering each reader's granularity filter, the
coverage-miss `null` return shape, the auto-router's window-to-tier
selection (90 d / 365 d / 1095 d), the fall-through chain on partial
coverage, and the linear-composition contract for count / min / max
/ mean / sumValue across coarser buckets.
Adds bounded findMany helpers for the WEEK / MONTH / YEAR rollup
buckets the writer has been minting since v1.5.0 but no reader
consults. `readBestGranularityRollups` auto-routes a window to the
coarsest granularity that still resolves it (90 d \u2192 DAY, 365 d
\u2192 MONTH, 1095 d \u2192 YEAR) with a fall-through to finer tiers
on coverage miss. `aggregateWmyBuckets` mirrors the DAY-side
compositional contract for count / min / max / mean and folds the
new `sumValue` column for cumulative-metric callers.
The sparkline aggregate row now carries `sum_value` (+ `count`)
alongside the bucket mean. Cumulative tiles — ACTIVITY_STEPS today,
every CUMULATIVE_HK_TYPES member tomorrow — render the daily SUM
rather than the daily MEAN, which previously understated the visual
trend signal by the per-bucket sample count.
Spot tiles stay on `mean`. Cumulative tiles fall back to
`mean * count` when the legacy NULL surfaces (pre-v1.4.39 buckets
waiting for the boot-time backfill to converge). The two pre-v1.4.39
$queryRaw select-list changes are additive and the cache wrap stays
unchanged.
`GET /api/measurements?source=rollup&aggregate=daily` previously
reconstructed cumulative daily totals from `mean * count`. The
v1.4.39 writer populates the new `sum_value` column on every fold,
so the read path consumes it directly. The legacy `mean * count`
arithmetic stays as the fallback for boot-backfill convergence
where existing rows still carry `sumValue = null`.
Algebraically equivalent (AVG = SUM / COUNT for a single-source
day), so this is a no-op for byte parity until the cumulative
single-source assumption breaks — at which point the bucket-level
SUM is the canonical reference rather than the reconstructed value.
The v1.4.39 schema scaffolding added `measurement_rollups.sum_value`
for the cumulative-metric daily totals (steps, flights, distance,
daylight, active-energy). Extends the rollup aggregator to also `SUM`
each bucket and persist the column for every (user, type,
granularity, bucket) row regardless of metric kind — spot metrics
carry the column for free because the SUM rides on the same
$queryRaw aggregate as AVG / MIN / MAX / STDDEV_POP / REGR_*.
Boot-time backfill discovery now unions in users whose existing DAY
rollup rows carry `sum_value IS NULL`. Re-folding converges those
rows because `persistRollupRows` always writes the new column on
upsert, and the union runs as one indexed pass alongside the per-
type missing-coverage discovery.
Adds `src/lib/measurements/rollup-read-cumulative.ts` with the new
`readCumulativeDaySums` / `readCumulativeDaySumsBatch` helpers
consumers use to swap off the per-type chunked findMany loop.
Helpers fall back to `mean * count` when the legacy NULL surfaces so
the chart never paints a hole during the boot-backfill convergence
window.
Audit anchor: .planning/round-v1438-perf-analysis.md §3 (cumulative
daily sums) + §5 P3.
Mirrors the measurement-rollups tier for the mood domain. Adds the
write-path hook (synchronous DAY upsert + async WEEK/MONTH/YEAR enqueue),
the bounded DAY reader, the warm-up helper, and the boot-time
backfill discovery. Closes the §2.3 finding in the v1.4.38 perf
analysis where /api/mood/analytics walked every MoodEntry on every
cold mount.
`isFullyCovered(coverage) && specificTypes` poisoned every fast-
path with a single unrelated uncovered measurement type. v1.4.38.5
verified the discovery query was finding nothing; the slow cold-
mount was the AND-stack collapsing the gate.
- correlations-fast-path: gates on SYS + PULSE + WEIGHT only.
- bp-in-target-fast-path: gates on SYS + DIA only.
- health-score-fast-path: gates on WEIGHT only.
Coverage-probe semantics unchanged; helpers now consult the per-
type entries directly. Power-user cold-mount on /api/analytics
estimated 30-75 s → ~3-5 s.
Rollup recompute observability + admin trigger.
- POST /api/admin/rollups/recompute synchronously folds one user or
re-runs the boot-discovery loop on demand. Admin-gated.
- slim_summaries live-fallback now annotates `fallback_reason` and
`missing_types[]` so the operator can map a slow cold-mount to
the type missing DAY-bucket coverage without DB shell access.
- Boot-backfill discovery now logs every result, including the
silent "no users to backfill" case — the previous shape made it
impossible to tell "discovery ran cleanly + found nothing" apart
from "discovery never executed".
See CHANGELOG.md.
v1.4.38.5 referenced `r."id" IS NULL` on the LEFT JOIN — but
`measurement_rollups` has a composite primary key
`(user_id, type, granularity, bucket_start)` and no surrogate `id`
column. Postgres rejected the query with `column r.id does not
exist` (SQL state 42703); the worker boot swallowed the error per
the helper's best-effort contract and the queue stayed empty.
Net effect: v1.4.38.5 deployed cleanly but delivered none of its
promised fast-path recovery. Marc's account stayed on the live
aggregator.
The fix uses `r."bucket_start" IS NULL` — any column from the
unmatched right side of the LEFT JOIN serves the "no matching row"
filter, and `bucket_start` is part of the composite PK so the
planner already has the column. Bumps to v1.4.38.6.
Analytics fast-path restored on long-running accounts. The boot-time
rollup-backfill discovery query was matching only ZERO-rollup users;
any account that ever folded silently stranded the moment a brand-
new measurement type landed. Discovery now widens to per-type
missing coverage so the boot-backfill queue picks these accounts up
on the next deploy and folds them back into the fast-path.
The v1.4.38.3 `AppError` boundary reloads once when a stale shell
trips `ChunkLoadError`, but the reload only fires AFTER the user
hits a navigation that lazy-loads a missing chunk. Users sitting on
the dashboard or insights overview ride out the staleness until
their next click; some never click and the cached shell silently
drifts further out of date with every deploy.
Three layered fixes so the running app heals itself instead:
- `public/sw.js` now keys both `STATIC_CACHE` and `PAGE_CACHE` to
the app release tag (`v1.4.38.4`). The `activate` step already
evicts any cache name outside the current pair, so bumping the
string at release time guarantees the precached root HTML and the
precached `/_next/static/*` chunks from the previous deploy are
dropped on the next SW install.
- `next.config.ts` injects `NEXT_PUBLIC_APP_VERSION` from
`package.json` into the client bundle so the running shell knows
which release it was built against.
- New `<VersionPoller>` client component mounts inside `<Providers>`
and polls `/api/version` every 60 s. If the live version moves
ahead of the shell-baked version it unregisters every active SW,
wipes CacheStorage, and `window.location.reload()`s.
`sessionStorage` gates the reload to once per session so a
mid-deploy webhook race that briefly returns a stale image cannot
loop the page.
After a deploy the cached SPA shell still references the old chunk
filenames. Next.js fetches them, the new server 404s the request,
and the user sees the generic error surface — typically with no
context other than "Failed to load chunk". The fix on the user's
side is a hard reload; before this commit they had to discover it
themselves.
`AppError` now detects the chunk-load family of errors
(`ChunkLoadError`, `Loading chunk`, `Failed to load chunk`,
`Failed to fetch dynamically imported module`) and triggers a single
`window.location.reload()`. `sessionStorage` gates the auto-reload
to once per session so an unrecoverable error doesn't loop into a
reload storm. Browsers in strict-privacy mode fall through to the
error UI cleanly.
The `RecentAchievementsCard` "View all" link in the dashboard card
header was 46×16 px. The mobile-viewport e2e check fails at the
WCAG-recommended ≥44 px touch-target floor and the existing project
contract pins the same number. `inline-flex min-h-11 items-center`
keeps the visual styling identical while satisfying the floor.
The v1.4.38 commit `41506c7a` added a `TODO(v1.5):` marker on the
correlations-fast-path `degraded` sentinel. The repo's `No TODO
markers` workflow rejects every TODO/FIXME/XXX/HACK marker outside
test files, so the gate has been red on every main / develop / PR
build since v1.4.38 landed. Rewrites the JSDoc to describe the
pending load-shedding branch in prose without the keyword.
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped:
- Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
twice a year for both the mood-reminder push and the medication
iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
object and other channel-specific extras to Apple's servers and
the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
unreachable for end users regardless of the other fixes.
v1.4.38.2 closes every item above plus adds the missing
`MoodReminderCard` Switch, a 90-day retention sweep for the dispatch
ledger, and a per-user `try` wrapper so one bad row cannot abort the
22:00 tick.
See CHANGELOG.md.
* feat(notifications): set APNs category for med-reminders
Unblocks iOS HealthLog v0.5.3 which registers a UNNotificationCategory
named MEDICATION_REMINDER with three actions (Take / Snooze 15 min /
Skip). Without aps.category on the push payload iOS renders the
notification as a plain alert, so the action-button row never
materialises on the lock-screen.
- ApnsPayload gains optional `category` + `mutableContent` fields.
The sender writes `category` through to node-apn's setter (a
forward-compat cast wraps the d.ts gap in node-apn 8.1).
- sendViaApns automatically forwards `payload.eventType` as the
category so every event-type that lands here surfaces as a
matching iOS category identifier without further plumbing. Future
event-types that the iOS app doesn't register fall back to a plain
alert — iOS silently ignores unknown categories.
- mutableContent=1 is set by default so an iOS Notification Service
Extension can hook the payload later without a server-side change.
- New unit tests pin aps.category=MEDICATION_REMINDER on the dispatcher
path, aps.category=MOOD_REMINDER for the new event-type, and the
explicit-category override on the lower-level sendApnsPush entry.
* feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger
Schema foundation for the v0.5.4 daily mood-reminder push:
- `users.mood_reminder_enabled` (Boolean, default false): per-user
opt-in flag. Default off because mood capture is an emotionally
loaded surface — a fresh-install user must explicitly toggle the
preference in Settings before the server starts nudging them.
- `mood_reminder_dispatches` (new table, unique on userId+date):
idempotency ledger that records exactly one nudge per user per
local date. Doubles as the race-guard for the every-15-min cron
re-ticking inside the same 22:00 window — the unique constraint
makes parallel workers safe even though the worker boots at
localConcurrency=1.
Migration is additive: existing users stay on the opt-out path
byte-identical to v1.4.38 behaviour. Uses IF NOT EXISTS guards
matching the idempotent pattern from 0061 / 0068.
* feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron
Second half of the v0.5.4 ios-coord patch. Now that the schema can
remember "user opted in" and "we already nudged today", wire the
worker side:
- types.ts: extend EVENT_TYPES with MOOD_REMINDER. Default off on
the per-event policy as a defence-in-depth gate behind the
per-user moodReminderEnabled flag.
- mood-reminder.ts: pure dispatcher module with three pinned-down
entry points so the unit tests don't have to spin up pg-boss:
* `evaluateMoodReminderWindow` — boundary contract for
"21:59 → skip, 22:00 → fire, 23:00 → skip" across any IANA tz.
* `buildMoodReminderPayload` — locale-aware title/body
("Stimmung erfassen" / "Wie geht es dir heute?" for DE,
"Log your mood" / "How are you feeling today?" for EN; falls
back to the app default for null/unknown locales).
* `runMoodReminderTick` — orchestrator that pulls opted-in
users, applies the window + already-logged + already-
dispatched filters, reserves the dedup row, and only then
calls the dispatcher. The P2002 unique-constraint violation
is caught and reclassified as "already dispatched" so a
lost race doesn't pollute the wide-event error stream.
- reminder-worker.ts: registers the `mood-reminder-check` queue
with a 15-min cron, mirroring the medication-reminder cadence so
any user's 22:00 boundary is caught within 15 minutes of wall-
clock without one-cron-per-zone. localConcurrency=1 keeps a
worker from racing itself. Also enriches the existing med-
reminder dispatch with a `scheduledAt` ISO string so the iOS
notification handler can pin "snooze 15 min" against the actual
schedule slot rather than the wall-clock moment APNs delivered.
- messages/{de,en,es,fr,it,pl}.json: new `moodReminders.dailyTitle`
+ `moodReminders.dailyBody` keys for all six locales.
- mood-reminder.test.ts: 13 unit tests covering opt-in gating,
window boundaries (21:59 / 22:00 / 23:00 + non-Berlin DST),
logged-today skip, ledger-already-exists skip, lost-race P2002
handling, and the multi-user multi-locale fan-out.
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.
- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.
See CHANGELOG.md for the full set.
The v1.4.37.1 fire-and-forget hotfix unblocked the event loop
but live HAR still showed /api/analytics?slice=summaries at
~3.1 s per cache miss on a power-user account. Root cause: the
v1.4.35 implementation read EVERY DAY rollup bucket for the
user without a bucketStart window and composed count/min/max/
mean in JavaScript. On the live account that materialised as
a 306k-row transfer plus a JS loop.
Replace the unwindowed prisma.measurementRollup.findMany with
a per-type $queryRaw GROUP BY. Returns 8 rows
(one per type with count/min/max/mean already shaped server-
side) instead of 306k bucket rows. The downstream JS aggregation
is bypassed because the per-type aggregate arrives pre-composed.
Cache-miss budget on Marc-sized accounts: ~3.1 s expected to
drop into the < 100 ms range.
The narrow aggregate query continues to carry the windowed
avg7/avg30 and regression slope/r2 columns that the GROUP BY
cannot reconstruct. Response shape is unchanged.
Hotfix on top of v1.4.37. The post-deploy verify caught
/api/analytics cold-mount hits of ~62 s and parallel /api/health
checks of ~58 s on the live instance, far above the v1.4.37
1.5-3 s perf claim. Root cause: the synchronous
ensureUserRollupsFresh(userId) call at the top of each heavy
read path folds the trailing 90-day DAY window when the rollup
watermark trails the newest measurement. On a power-user account
whose iOS step samples push the watermark every few minutes,
each cache miss synchronously paid the full refresh cost on a
Node worker that also serves the rest of the request fan-out
(including /api/health, /api/version, and concurrent iOS calls),
and the event loop was blocked for the duration of the
recompute.
Convert the three call sites (analytics route, comprehensive
aggregator, summaries slice) to fire-and-forget. The downstream
probeRollupCoverage gate falls back to live SQL on partial
coverage so correctness is preserved; the only user-observable
change is one cache window of staleness on the very first
request after a new measurement landed.
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
Removes the operator step that was implicit in the v1.4.35 foundation
release. Self-hosters no longer need to run scripts/backfill-rollups
after upgrade; the worker boot discovers any account with measurements
but no rollup coverage and enqueues a one-shot full fold via pg-boss.
New rollup-full-backfill queue + handler folds one user per job at
serial concurrency. enqueueBootTimeRollupBackfill runs once at
startReminderWorker boot, idempotent across reboots via the
discovery query (matches only zero-rollup accounts) and a
singleton-key per user so fast restarts coalesce.
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: 4285 unit + 230 integration. tsc + lint clean.
Foundation release for the persistent measurement_rollups cache tier.
Adds the additive table + enum, populates it from every measurement
write hook, lands the Apple-Health-import end-of-job backfill, and
ships the parity-checked read-swap on two reader surfaces.
The comprehensive insights aggregator and the slim analytics
summaries slice now source count / min / max / mean per type 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.
Includes the new scripts/backfill-rollups.ts CLI for the one-shot
fold of historical measurements into all four granularities.
Tests: 4280 unit + 228 integration. tsc + lint clean. Migration
0067_v1434_measurement_rollups is idempotent under re-run.
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
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.
First release that lets v1.4.34.2's `pull_policy: always` show its
worth — the deploy mechanic should pick up the new image without a
manual host-side retag fallback.
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.
Changed:
- /api/insights/comprehensive now reads through a SQL-side aggregator
(src/lib/insights/comprehensive-aggregator.ts) grouping by metric
type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
the measurements (user_id, type, measured_at) index path. Returns
the same DataSummary-shaped per-type bundle the legacy route
stitched together; the medication-compliance block keeps its
bounded Prisma reads. Wrapped through the server cache keyed on
`${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
to a few hundred ms against fixture data matching the production
volume that triggered the bug; warm path resolves in single-digit
milliseconds.
- Cache-wrap the five remaining read routes the v1.4.34 server-cache
blueprint left staged but unwired: /api/mood/analytics,
/api/workouts, /api/bugreport/status, /api/medications,
/api/dashboard/widgets. The Cloudflare 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream
consequence — once the comprehensive route stops monopolising the
pool, the origin accepts new connections fast enough that
Cloudflare's circuit-breaker no longer trips.
- /api/dashboard/chart-overlay-prefs PUT now invalidates the new
dashboard-widgets cache so the partial-update path doesn't leave
stale layout state in the cache for up to TTL.
Fixed:
- Scatter-correlation card no longer logs the Recharts
`width(-1)/height(-1)` warning on first mount. The wrapper now
carries a min-h-[180px] floor that matches the loading skeleton so
ResponsiveContainer always measures a non-zero parent before
aspect-ratio settles.
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
Categories endpoint + conflict-resolution lock. A two-item follow-up to v1.4.30: the categorisation overlay shipped yesterday as a TypeScript map; the new `GET /api/measurement-categories` endpoint projects it over HTTP so the iOS-side picker can fetch the canonical shape rather than carry a hard-coded mirror — the unblocker the iOS team flagged as the one missing piece. In parallel the SyncMode conflict-resolution policy that the v0.6.0 standalone-first track depends on lands as §13 of `08-locked-contracts.md`.
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
Daily-step aggregation hotfix. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages, by routing cumulative HealthKit types through the sum branch of the client-side daily aggregator.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix
"Speichern" on the Dashboard works again for every account whose
layout was last persisted before v1.4.28. The v1.4.28 retire of the
`glp1` widget id left an orphan entry in `dashboardWidgetsJson` for
legacy users, which made the save validator reject the payload; the
resolver now filters unknown widget ids on read.
### Fixed
- **Dashboard save against legacy layouts.**
`resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops
widget ids that are no longer in the registry before returning
the layout, so the `dashboardWidgetsJson` round-trip
(`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`)
succeeds even when the persisted JSON still names `glp1` or any
other retired widget.
### Tests
- `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the
retired-id filter (19/19 green).
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Coolify runbook + audit briefs scrub the live deploy URL to a
placeholder (Sec-MED-2). Real values live in repo secrets.
- /api/auth/me/devices isCurrent now compares against the session's
device id, not the unauthenticated X-Device-Id header (Sr-MED-5).
- Migration 0036 sleep-duration unit shift documented + verification
command added to the coolify-auto-deploy howto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>