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.
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 — every
force-rebuild finished in ~20 s without ever fetching the new
manifest.
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. From v1.4.34.2 onwards, every release picks up its
own 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.
Issue #167 (Warsaw user, v1.4.23) reports that the CSV export reads as
UTC because `Date.prototype.toISOString()` writes the `Z` suffix and
spreadsheet viewers drop the marker. The technical bug is small — 4
lines in `src/lib/export.ts` — but the architectural surface it
exposes is large: the whole app is hardcoded to `Europe/Berlin` for
display, analytics buckets, reminder cron, Withings sync mapping,
charts, PDF, Coach context (10 distinct symptoms total).
This commit records the long-form analysis so a future planning pass
does not start from zero:
- `.planning/feature-user-timezone.md` — full proposal: symptom
catalogue across 10 surfaces, design space (A: export-only quick
fix, B: per-user `displayTimezone` setting, C: rejected
auto-detect), migration strategy with concrete file/line touches,
the date-string risk split (`MoodEntry.date` freeze vs
`MedicationSchedule.windowStart` re-anchor — they need different
contracts), DST edge cases, parametrised test plan (Berlin / UTC /
New York / Tokyo / Kiritimati), and open product-call questions
for the v1.5 planning pass.
- `.planning/v15-backlog.md` — one-line carry under a new "From issue
triage" section pointing at the proposal.
- `.planning/ROADMAP.md` — reserved-milestones block notes per-user
timezone as a v1.5 candidate, with v1.6+ as fallback if v1.5 stays
iOS-focused.
Scope-clean: the v1.4.25 quick fix (ISO-8601-with-offset CSV) is
documented as independent. It closes#167 on its own without
touching the architectural scope captured here.
Pre-iOS prep + hygiene. Apple Health backend foundation (schema +
batch ingest + APNs scaffolding), OpenAPI generator + warn-only CI
gate, per-device refresh-token revocation + device-management
endpoints, Coach Apple Health metric slot at PROMPT_VERSION 4.23.0,
per-user Coach prefs surface, per-message thumbs feedback loop.
Seven W5 hygiene items closed alongside.
User-visible delta is small on purpose — the weight is in the
contract surface the iOS app gets to compile against on day zero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zod-openapi emits long descriptions inline; the committed copy carried
a stale newline+indent fold. Generator output is what `pnpm openapi:check`
compares against, so re-snapshot before tagging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the artifacts referenced from STATE.md, v1423-backlog.md, and
phase-W6-v1423-reconcile-report.md but never committed during the
wave-close ticks. W1 research = Apple Health type mapping + APNs
library decision + OpenAPI tooling choice; W5 v1422 reviews + W6
v1423 reviews are the six-brief multi-agent QA inputs that drove the
reconcile commit chain.
Leaves the pre-existing stale untracked entries (v1422-w1a probes,
audit-a6 scripts, export-route directories) untouched — those predate
v1.4.23.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
11 reconcile commits across Sessions A/B/D/E/F closed 1 CRIT and
all 9 HIGH findings. STATE.md ticks Wave 6; v1423-backlog.md
captures the deferred MED + LOW items + the settings-cog-vs-thumbs
debate for v1.4.24.
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>
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.
Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.
Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer note: src/app/api/analytics/route.ts fanned out 17+
unbounded prisma.measurement.findMany per request — growth linear
in HealthKit-days. Replace with prisma.groupBy where possible;
chunked-paged with take=5000 where running-window slope or paired
correlations require row-level access. Wide-event annotation
analytics.bp_aggregate.row_count for slow-query attribution.
Fixes: Sr-H1 (Wave-6 v1.4.23 senior-dev review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both DELETE /api/devices/[id] and DELETE /api/auth/me/devices/[id]
performed the same four-write cascade (refresh-token lookup, refresh
revoke, access-token revoke, device delete) as separate Prisma calls.
A partial failure between the third and fourth write left the device
row alive with all its tokens revoked - the user's iPad showed in the
device list as "still registered" but every request returned 401.
Extract the cascade into a shared revokeDeviceCascade() helper and
wrap the writes in prisma.$transaction so they commit atomically.
Both routes now call the helper; the audit-log via tag and the wide
event action name stay route-local.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small reuse wins from the W6 simplify review:
- openapi/registry.ts swaps the lazy require() for a static import.
The ESLint disable + as-cast carried no real benefit; the only
callers always loaded the route table anyway.
- Extract useCoachPrefs() into a shared hook so the settings sheet
and message thread share one queryKey + one fallback shape. Removes
the drift between the two existing inline blocks.
- Replace the 9-deep wantsX boolean ladder in coach snapshot with a
single CoachScopeSource -> MeasurementType[] map. Drives both the
WHERE type IN (...) build and the appleHealthBlocks gate, so adding
a new metric is one entry instead of three parallel edits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render a skeleton shell while the persisted Coach prefs are loading
so the form never paints DEFAULT_COACH_PREFS as a ghost between mount
and the query resolving. Emit a sonner toast on save success before
closing the sheet so the action is visibly acknowledged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Apple Health pink chip rendered only inside the desktop `<Table>`
cell. iOS users — the audience that actually consumes Apple Health
data — land on the mobile card branch and saw no source indicator at
all, risking duplicate hand-entries on top of synced rows. Add a
compact badge inline with the timestamp on the mobile card so the
distinction lands at every viewport. Component test asserts the
`measurement-source-badge` testid appears twice (desktop + mobile)
so the next layout refactor can't silently drop one branch.
Also extends the `POST /api/devices` unit test mocks for the
NotificationChannel auto-upsert added in the previous commit, plus a
new test that pins the same-user apnsToken collision path returning
201 idempotently (the cross-user path keeps its 409).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two collision paths let the same APNs token attach to multiple Device
rows for one user, fanning a single push out to two rows and double-
charging the APNs quota:
1. The cross-user-hijack guard filtered `NOT: { userId }` so it
skipped same-user collisions entirely.
2. The `apns_token` index was non-unique.
Drop the `NOT: { userId }` clause so the lookup catches both cases —
cross-user collisions still 409, same-user collisions fall through to
the existing legacy-token upsert path. Pair with migration 0041
introducing a partial UNIQUE index over `apns_token IS NOT NULL` and
dropping the redundant non-unique sibling. Apple's APNs contract
guarantees one token per (device, app), so global uniqueness matches
the upstream invariant.
Migration is strictly additive at the rolling-upgrade boundary; the
NULL-allowed partial-index shape preserves legacy iOS rows without an
APNs handshake. Schema-side comment documents why the partial index is
introduced via raw SQL (Prisma can't express it natively) and warns
against re-adding the non-unique sibling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.
When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.
Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The coach-feedback slug was registered in ADMIN_SECTION_SLUGS and the
page rendered correctly via /admin/[section], but the entry was never
added to the hand-maintained ADMIN_SECTIONS array. Operators could only
reach the view by typing the URL directly. Add the entry next to
ai-quality so the two AI-quality views sit together, and add a drift
guard test that walks ADMIN_SECTION_SLUGS and asserts every slug has a
matching nav entry. Same drift class would recur on the next admin
section addition without the guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight commits closed all seven items from `.planning/v1422-backlog.md`.
STATE entry documents the per-item changes; new
`phase-W5-v1423-report.md` captures the H5 deploy decision, the H7
admin view path + the first useful question it answers, and the
OpenAPI registry additions (3 routes — H4 GET + PUT, H7 POST).
Test deltas: 2191 → 2223 unit (+32), +2 integration files, no
regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Pearson p-value derives from the Fisher's-Z normal approximation.
At low df (df < 18, n < 20) the normal approx is generous in the
wrong direction — at df=12 a true p≈0.04 reports as p≈0.025, sneaking
past the p<0.05 surfacing gate. The v1.4.22 product-lead memo flagged
this as Code-MED-03 with a deadline of "before v1.5/v1.6 auto-
discovery ships" because the false-positive cost compounds when
correlations get auto-generated.
Two paths considered:
- Rigorous: drop in a 30-LOC incomplete-beta implementation so the
p-value is accurate at every df. Cleanest fix, no test churn.
- Conservative: raise `MIN_PAIRED_N` from 14 to 20 so the borderline
band is excluded entirely. Smaller patch, no maths-library
dependency.
v1.4.23 ships the conservative fix. The rigorous incomplete-beta
swap is queued as a v1.4.24 candidate; if usage data from the
v1.4.16 B5e feedback aggregator later shows users miss the
borderline cards, the rigorous path lands then.
Trade-off: a small set of borderline-significance correlation cards
stop rendering. Acceptable for v1.5 — the prompt-injection guards +
warm Coach prose make it more important to under-report than over-
report patterns.
Tests +1 (`pearson` n=15 → insufficient pin) plus an existing
`correlateBpCompliance` n=13 test renamed to n=15 + the noise-fixture
extended from 15 to 20 entries to clear the new gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The schema declared `MedicationSchedule.daysOfWeek` (mapped to
`days_of_week`) but no migration ever created the column. Code
paths in src/components/medications/*, src/app/api/medications/*,
src/app/api/admin/notifications/reminder-check/*, and the
achievements route all reference it via the comma-separated
"0,1,3,5" recurrence string.
Pre-v1.4.23 deploys silently survived because the existing query
sites only selected the column on SELECT — Postgres would have
failed loudly the moment a user actually saved a non-default
recurrence string via the medication form.
Migration `0039_medication_schedule_days_of_week` adds the column
nullable, default NULL. NULL means "daily" — `parseScheduleRecurrence(null)`
already returns the all-7-days set, so existing rows get the correct
semantics without backfill.
H5 decision rationale: deploy (Option A) over drop (Option B). The
field is referenced by 9 source files across 4 user-visible surfaces
(medication form, card, intake list, reminder dispatcher). Removing
it would force a UI rewrite of the recurrence picker and break
existing test fixtures. Deploying matches the schema's stated intent
and unblocks the v1.4.21 senior-dev review's MED-5 flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.20 implementation reset the composer by mounting the
`<CoachDrawer>` with `key={prefill}`. That weaponised React's
key-reset to flush internal state every prefill change — Sr-HIGH-4
flagged it as a smell because the next refactor that touches the
drawer's mount point silently loses the reset semantics.
Replace with the controlled-prop pattern React documents at
https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes:
new `useResettableValue` hook reads the controlled prefill, mirrors
it via a sibling useState pair, and seeds the composer with the new
value when the prop changes. Render-phase setState — ESLint-clean
against `react-hooks/set-state-in-effect`. The user can still type
freely; the drawer only resets when the parent changes which
suggested-prompt chip is active.
`nextResettableValue` exposes the comparison contract as a pure
helper so the unit test can pin the (Object.is) semantics without
standing up a React renderer.
The `<CoachDrawer key={…}>` is gone from the insights page mount.
Tests +5: pure-function coverage of unchanged value (no reset),
prefill change (reset), null↔empty transition (reset), suggestion
chip → cleared prefill (reset), and NaN-to-NaN edge case (no reset
under Object.is).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The W2-of-v1.4.20 BD-Zielbereich fix needed the complete sys + dia
series to compute the all-time + prior-month + prior-year windows,
so it pulled both tables via single-shot `findMany`. For a 5-year
power user (~9 000 rows per side) that's a 50-100 ms Prisma round-
trip and a ~2 MB working-set allocation per /api/analytics request.
Switch to a cursor-paged 5 000-row chunked read via the new
`fetchBpSeriesChunked()` helper. The accumulated `BpReading[]` is the
same shape `computeBpInTargetWindows()` already consumed; the
working-set ceiling is now `BP_CHUNK_SIZE` instead of unbounded. Safety
bound at 1 000 pages = 5 M rows so a cursor-staleness bug can't loop
forever.
Wide-event annotation `analytics.bp_in_target.row_count` (+ per-side
`sys_rows` / `dia_rows`) lets ops attribute slow requests to outlier
users without rerunning the DB queries.
Integration test seeds a 6 000-row dataset (3 000 sys + 3 000 dia)
spanning a chunk boundary and asserts the response shape stays the
same — last30Days, last7Days, allTime windows all carry the
hand-derivable percentages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.
- `SentinelParseResult.malformedEntries[]` records each skipped row
with a typed reason (`missing_colon` / `value_overflow` /
`label_overflow` / `no_END_marker` / `byte_overflow` /
`schema_invalid`). Block-level failures (truncation, missing close
marker) land here as synthetic entries so the wide-event always
carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
parser so the row-level reason can be `label_overflow` /
`value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
when at least one row parsed AND at least one row failed, the
existing `coach.keyvalues.parse_failed` stays for full-block
failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.
Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave 4 closes the v1.4.23 foundation track. F5 (OpenAPI 3.1 drift
gate, warn-only for v1.4.23), F6 (Coach schema slot for Apple Health
metrics + PROMPT_VERSION 4.23.0), F7 (per-device refresh-token reuse
detection + device-management endpoints), and F8 (Coolify auto-deploy
runbook) all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two halves of the contract — workflow-side webhook (v1.4.22 C3 force=true
already in place) and Coolify-side image-digest watch toggle — must
both be on for tag pushes to land on production without an SSH retag.
The runbook in .planning/coolify-auto-deploy-howto.md now spells out
both pieces verbatim plus the verification curl + host-side fallback.
The workflow gains a single ::notice:: line after the webhook call so
future Actions runs surface the timestamp + sha for triage without
opening the verbose log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>