Commit Graph
1797 Commits
Author SHA1 Message Date
Marc-André Bombeck d2bad6943c perf(insights): route daily chart fetches through rollup buckets
The Insights trends row fires three parallel
GET /api/measurements?type=…&aggregate=daily&limit=5000 requests on
mount (BP_SYS / BP_DIA / WEIGHT). The legacy date_trunc path scanned
the measurements table each time — ~3 s per call on Marc's account.

Add a `source=rollup` opt-in on the listMeasurements schema. When set
alongside `aggregate=daily` the route reads from measurement_rollups
DAY buckets instead, swapping a full-table scan for a small indexed
read against the ~5 k-row rollup table. Cumulative HK metrics get
mean * count to reconstruct the daily SUM the chart expects.

The chart-data client opts in for every windowDays > 7 fetch so the
trends row + every sub-page chart benefit. When the rollup returns
zero buckets for the requested window the route falls through to the
live aggregate so brand-new accounts still render their first chart
correctly.
2026-05-17 12:34:00 +02:00
Marc-André Bombeck 6884995995 feat(insights): compactSections drops zero-row blocks before serialization
Walk the features payload and the Coach snapshot through a shared
compactSections helper before JSON.stringify so the prompt never
carries labelled-empty keys ("sleep": [], "medications": {}, etc.).
Empty arrays, empty plain objects, and undefined values are dropped;
null is preserved (it signals "unknown", which the model handles
differently than "missing block").
2026-05-17 12:31:15 +02:00
Marc-André Bombeck 9feca9e015 fix(insights): tighten tab-strip availability gate (actual W4d files)
Re-commit because the previous attempt grabbed an unrelated parallel
change. The tab-strip + its tests are the W4d touch.
2026-05-17 12:30:27 +02:00
Marc-André Bombeck 5b44cb6a65 fix(insights): tighten tab-strip availability gate
The pre-v1.4.36 fallback rendered every metric pill while the
analytics fetch was in flight. Users with zero observations on a
metric briefly saw nav targets they couldn't act on, and on a
failed or slow fetch the pills stayed up forever — clicking
through landed on an empty-state page.

The strict gate flips the contract: no availability => only the
Overview pill renders, then metric pills light up progressively
as `summaries[METRIC].count` flips from 0 to ≥1. This matches
the auto-light-up behaviour the original metric-availability
helper was designed for.

The dashboard tile gate and per-sub-page empty-state gate already
applied the strict reading; the tab strip is now in lockstep.
2026-05-17 12:29:59 +02:00
Marc-André Bombeck 7352806184 feat(insights): per-user exclude-metrics opt-out (mirrors Coach)
Add insightsExcludeMetrics to the User schema and apply the list as
a feature-payload filter inside /api/insights/generate before
JSON.stringify. The Coach settings PUT mirrors its excludeMetrics
onto the new column so both surfaces share a single privacy
contract — no separate Insights settings panel needed.

Anthropometrics is special-cased to strip only height / age / gender
inside features.context, leaving the aggregate totals intact.
2026-05-17 12:29:09 +02:00
Marc-André Bombeck 13f8905ea1 perf(insights): skip heavy live aggregate when rollups are populated
v1.4.35 left the regression intact: comprehensive-aggregator and the
slim summaries slice still ran the full COUNT/MIN/MAX/AVG `$queryRaw`
in parallel with the rollup read, only preferring the bucket-derived
values when the count happened to match. Net round-trip count saved
was zero on Marc's 311k-row account.

Promote the rollup table to the canonical source on the happy path:
gate on a cheap `SELECT COUNT(*) FROM measurement_rollups` and, when
non-zero, drop the heavy aggregate. The bucket composition feeds
count/min/max/mean, a narrow `$queryRaw` keeps the non-composable
windowed columns (stddev/anomalies/avg7/avg30/avg30_last_month + the
three slope tuples), and the legacy heavy aggregate stays as the
cold-mount fallback for accounts whose backfill is mid-flight.

Unit tests pin the contract: the heavy aggregate is NOT called on the
rollup-fresh branch. Integration test verifies byte-identical count /
min / max / mean against a parallel live SQL pass on a populated
fixture.
2026-05-17 12:28:01 +02:00
Marc-André Bombeck 3f55b6f782 fix(dashboard): bucket cumulative metrics per day before summarising
The Steps tile, Active-Energy tile, Walking + Running Distance tile,
Flights-Climbed tile and Time-in-Daylight tile all rendered the
*latest minute-level slice* from Apple Health as `summary.latest`
instead of the day's total. The user-facing result was a Steps tile
that read "1,247 steps today" when the actual day sum was closer to
8,000 — the tile was showing the freshest slice, not the running total.

SLEEP_DURATION already ran the per-day-source-pick + per-day-sum
collapse inline in /api/analytics. Extract that reduction into
`pickCumulativeDaySum` and broaden it to ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, and
TIME_IN_DAYLIGHT.

Each cumulative type maps to its own SourcePriorityMetricKey for
the canonical-source pick (TIME_IN_DAYLIGHT falls through because
there is no clinical-grade competitor today). After the collapse,
`summary.latest` is the most recent day's total, which is what the
dashboard tile expects.

Tests: 7 new unit cases on the pure helper; SLEEP_DURATION
integration regression still green.
2026-05-17 12:26:02 +02:00
Marc-André Bombeck 1e9a03bee5 feat(coach): medications + anthropometrics exclusion toggles
Extend the coach exclude-metrics enum with two optional-context tokens
and surface them in the settings sheet under a separate "Include
context" group. The snapshot gates the GLP-1 weeklyContext block on
the medications toggle (skipping the Prisma lookup entirely when
excluded) and gates the new anthropometrics block on the
anthropometrics toggle plus at least one non-null profile field.
Empty blocks are never emitted.
2026-05-17 12:25:30 +02:00
Marc-André Bombeck 83f0b5ae86 ux(nav): pin active tab-strip chip to the scroll start
The Settings + Admin shells centred the active chip via
`scrollIntoView({ inline: "center" })`. On narrow viewports the
strip over-scrolled past the leading chips so the first one or two
were unreachable until the user manually swiped right. `inline:
"start"` lands the active chip at the leading edge so the trail of
preceding chips remains visible.
2026-05-17 12:21:31 +02:00
Marc-André Bombeck a17e7a46af feat(medications): restore intake-history table on the detail page
The v1 IntakeHistoryList was retired in v1.4.28 on the assumption
it had no other consumer. The standalone /medications/[id]/history
page lost its dose timeline as a side-effect — non-GLP-1 medications
now render only the heading. This restores a slim, read-only v2.

- Paginated 25/page via the existing limit/offset query params
- Sortable by takenAt (default desc) and scheduledFor
- Status and source badges; no inline edit or delete
- Empty state with CTA back to the daily intake page
- i18n keys across all six locales (DE + EN curated, the AI-initial
  locales mirror the EN copy per the v1.4.25 W9e maintainership policy)
2026-05-17 12:21:00 +02:00
Marc-André Bombeck 28a829695d fix(insights): tri-state render contract for trend annotations
The trends-row annotation slot painted "Mehr Daten nötig, um diesen
Trend zu kommentieren" whenever the advisor query was in flight or a
regenerate was firing — even when the model was about to deliver
fresh prose. Two cold-mount conditions collapsed into the same empty
hint: advisor pending, and advisor returned null.

`<TrendAnnotation>` now reads a tri-state `status` prop:

  - `pending`     — advisor query in flight / regenerate firing.
                    Renders a 3-line shimmer block.
  - `needs_data`  — advisor returned `annotation = null`. Renders the
                    "Mehr Daten nötig" hint.
  - `generated`   — advisor returned a string. Renders the prose +
                    optional confidence chip.

When `status` is omitted the legacy `annotation == null → empty`
mapping still applies (back-compat for existing callers).

`<TrendsRow>` derives the status from a new `loading` prop and the
per-metric annotation presence, with pending winning over needs_data
so a mid-regenerate cycle never flashes the empty hint between
shimmer and new prose. The Insights mother page now passes
`loading={advisor.isLoading || advisor.isRegenerating}`.

Added unit tests pin the three render-state branches plus the
back-compat fallback. New `trendAnnotation.pendingLabel` i18n key
across DE / EN / FR / ES / IT / PL drives the aria-label on the
pending block. Health Score paint lag is a parent-level analytics
query issue and stays for v1.4.37.
2026-05-17 12:19:43 +02:00
Marc-André Bombeck 2e2ca2242f feat(insights): bucketed measurement payload for raw mode
Replace the per-row rawMeasurements dump with DAY/WEEK/MONTH bucket
means read from measurement_rollups. The legacy shape blew past
Codex's 10 MB string limit on power-user accounts; the bucketed shape
stays O(types × buckets) regardless of row count.

Enforce a 5 MB ceiling on the serialised payload; the generate route
downgrades to the aggregated shape and annotates when the cap trips so
future regressions surface in logs.
2026-05-17 12:14:45 +02:00
Marc-André Bombeck b4bef03fd7 feat(charts): mini ChartSkeleton variant for trends-row slot
The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.

Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.

Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
2026-05-17 12:11:55 +02:00
Marc-André Bombeck 4d111e39dd fix(insights): pin trends-row chart slot to h-[140px]
Each `trends-row-chart-slot` wrapper had `shrink-0` but no explicit
height. Recharts' ResponsiveContainer mounted with `width=-1
height=-1` and emitted a warning on every Insights cold mount; the
mood card visibly drifted taller than BP / weight as the chart
hydrated, contributing the bulk of the Lighthouse 0.46 CLS hit.

Pin the wrapper to 140 px (matches the chart-internal mini default).
Recharts now mounts at a known size, the three cards line up on the
same baseline from first paint, and the warning is gone. Test pins
the height on all three slots so a future refactor has to revisit
the row rhythm intentionally.
2026-05-17 12:07:27 +02:00
Marc-André Bombeck 125457e4fd docs(planning): v1.4.36 marathon handover — perf, charts, AI, UX punch 2026-05-17 12:03:49 +02:00
Marc-André Bombeck 7c6473304b docs(planning): v1.4.35.1 closure addendum — auto-converging boot backfill 2026-05-17 11:39:17 +02:00
Marc-André Bombeck 5a3ca330ac v1.4.35.1 — auto-converge rollups on worker boot
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.
v1.4.35.1
2026-05-17 11:29:00 +02:00
Marc-André Bombeck 01ae3b542a feat(rollups): v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation.
Self-hosters no longer need to run the backfill script after upgrade.

New `rollup-full-backfill` queue + handler folds one user per job at
serial concurrency. `enqueueBootTimeRollupBackfill` runs once at
`startReminderWorker` boot, discovers accounts with measurements but
zero rollup coverage, and enqueues one full-fold job per uncovered
user with a singleton key so fast restarts don't pile up duplicates.

Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.

Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
2026-05-17 11:28:21 +02:00
Marc-André Bombeck ba9dd3fd2f docs(planning): v1.4.35 closure addendum — Layer B landed (no longer deferred) 2026-05-17 09:59:09 +02:00
Marc-André Bombeck 8dc9edb65e v1.4.35 — persistent measurement rollups + partial read-swap
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.
v1.4.35
2026-05-17 09:55:33 +02:00
Marc-André Bombeck 67ccd55398 feat(rollups): v1.4.35 — partial read-swap onto measurement_rollups
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
from the DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets.

A defensive parity check against the live aggregate's COUNT(*) falls
back to live SQL when divergence is detected: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.

Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.

tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
2026-05-17 09:53:41 +02:00
Marc-André Bombeck aede0e9377 docs(planning): v1.4.35 handover — finish Layer B (read-swap + ship) 2026-05-17 09:33:27 +02:00
Marc-André Bombeck 9872081d0e feat(rollups): MeasurementRollup foundation — populator + write hooks + warm-on-read
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.

Schema:
  - new MeasurementRollup model with composite PK
    (userId, type, granularity, bucketStart) and a descending
    bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
  - Prisma migration 0067_v1434_measurement_rollups is additive:
    CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
    EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
  - db-compat schema-bootstrap path mirrored.

Populator (src/lib/measurements/rollups.ts):
  - recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
    pg-boss (singleton-keyed).
  - recomputeUserRollups: bounded full-range, used by Apple Health
    import + backfill.
  - ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
    recompute. Error-swallowing.
  - SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
    unit (Postgres 42803 forbids binding the unit literal).

Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.

Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).

Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.

Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.

Read-swap follow-up:
  - comprehensive-aggregator + analytics/route + summaries-slice
    each call ensureUserRollupsFresh as a read side-effect (warms
    the table). The response stays on the live-SQL aggregator so
    byte-shape parity with v1.4.34.5 is preserved.
  - Next commit lands the partial read-swap: count/min/max/mean
    and dailyByType from buckets (math-identical compose), while
    slope/r2/sd stay on indexed live aggregation. Together they
    ship as v1.4.35.

Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
2026-05-17 09:32:30 +02:00
Marc-André Bombeck 2982ed1f0b docs(planning): audit-marathon closure (v1.4.34.4 + v1.4.34.5)
End-to-end summary of the eight-axis audit + the two consecutive
hotfix releases that landed the hotfix-ready findings: security,
UX/a11y, code quality, mobile-deep, docs depth, README + GitHub
discoverability, perf, and test coverage. Lists what shipped, what
got deferred to v1.5.x, the operator-action delta (one obsoleted),
and the quantitative deltas (24 commits, 2 releases, 55 net new
tests, 5 new user-facing docs).
2026-05-17 00:54:43 +02:00
Marc-André Bombeck 9715831f82 v1.4.34.5 — audit follow-on: critical-path tests + iOS textarea zoom
Follow-on to v1.4.34.4.

Tests:
  24 new integration tests across 5 files closing the highest
  critical-path gaps the test-coverage audit flagged:
    - auth-password-change.test.ts pins the v1.4.34.3+
      destroyAllSessions three-transport revocation contract.
    - admin-reset-password.test.ts mirrors the same shape for
      admin resets.
    - withings-oauth.test.ts covers connect / callback / disconnect
      + state-token replay rejection.
    - passkey-register.test.ts covers register-options +
      register-verify + tampered-attestation + challenge single-use.
    - cache-invalidation-coverage.test.ts grids the IW-G
      invalidation matrix across 5 write surfaces.
  Integration suite: 197/50 → 222/55. tsc + lint clean.

Mobile:
  Five textareas no longer trigger iOS Safari zoom-on-focus. The
  Coach composer, bug-report description, admin feedback note,
  medication-side-effects notes, and medication-intake JSON
  import all shipped text-sm (14 px). iOS Safari zooms whenever
  an input renders below 16 px. Switched each to
  `text-base sm:text-sm` so mobile clears the floor while desktop
  keeps the compact look.
v1.4.34.5
2026-05-17 00:53:03 +02:00
Marc-André Bombeck e1bbb99f7d chore(release): v1.4.34.5
Follow-on to v1.4.34.4: 24 new critical-path integration tests
(auth-password-change + admin-reset-password + withings-oauth +
passkey-register + cache-invalidation-coverage) and the textarea
iOS-zoom-on-focus fix across 5 sites. Integration suite goes from
197/50 to 222/55.
2026-05-17 00:52:44 +02:00
Marc-André Bombeck 03e8627765 mobile: textarea text-base on mobile to suppress iOS Safari zoom-on-focus
Five textareas in the app (Coach composer, bug-report description,
admin feedback note, medication-side-effects notes, medication-intake
JSON import) shipped `text-sm` (14 px) on every viewport. iOS Safari
triggers a viewport zoom-on-focus whenever an input renders text
below 16 px — so every tap on these surfaces yanked the page scale
and rebounded on blur. Switched each className to `text-base sm:text-sm`
so the mobile baseline clears 16 px while desktop keeps the compact
look the rest of the form chrome converged on.

No shared <Textarea> primitive exists yet — F-5's deferred work — so
the fix is per-call-site. Five files, one className change each, no
component re-shape.
2026-05-17 00:51:47 +02:00
Marc-André Bombeck 02003428df test(integration): 24 critical-path tests across auth, withings, cache
Closes the highest-severity gaps from the v1.4.34.3 test-coverage
audit. Five new integration files, 24 new cases.

auth-password-change.test.ts (5 cases):
  Pins the v1.4.34.3+ `destroyAllSessions` contract end-to-end.
  Asserts that after a successful password change the user's
  Session.deleteMany ran, ApiToken.revoked flipped to true, and
  RefreshToken.revokedAt was set. Plus wrong-current-password (401)
  and weak-new-password (422) negative paths. A second
  concurrent-request that holds the old session cookie is rejected
  by the next session-protected endpoint.

admin-reset-password.test.ts (5 cases):
  Same three-transport revocation shape for the admin-driven
  reset. Non-admin auth rejects with 403. Audit-log row carries
  the action name `admin.user.reset-password` and the actor's
  user-id.

withings-oauth.test.ts (5 cases):
  GET /api/integrations/withings/connect returns the upstream
  authorise URL with the right scopes + redirect URI. GET
  /api/withings/callback exchanges code for tokens by stubbing
  the Withings token endpoint; the user row's encrypted token
  pair populates. POST /api/withings/disconnect clears them.
  State-token replay (wrong cookie) rejects with 400. The
  Set-Cookie header carries the URL-encoded form of the state
  string — the assertion compares on the encoded value, not the
  literal.

passkey-register.test.ts (4 cases):
  /api/auth/passkey/register-options issues a fresh challenge
  bound to the RP-ID. /api/auth/passkey/register-verify against a
  stubbed @simplewebauthn/server verifier creates the Passkey
  row. A tampered attestation rejects with 422 + a clear
  errorCode. The challenge is single-use.

cache-invalidation-coverage.test.ts (5 cases):
  Grids the v1.4.34 IW-G invalidation matrix across 5 write
  surfaces: measurement POST evicts caches.analytics; mood-entry
  POST evicts caches.moodAnalytics; medication POST evicts
  caches.medications; dashboard-widgets PUT evicts
  caches.dashboardWidgets; admin app-settings PATCH evicts
  caches.bugreportStatus. The integration setup's `isolate: false`
  parallel-DB-bleed quirk on app_settings was handled with a
  `vi.importActual("@/lib/db-compat").ensureDbCompatibility()`
  in beforeEach — pattern worth noting for any future test
  touching that table.

Run: `pnpm test:integration` reports 222/222 passing across 55
files (was 197 in 50 files before this batch); tsc + lint clean.
2026-05-17 00:49:57 +02:00
Marc-André Bombeck f5962497d6 v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
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.
v1.4.34.4
2026-05-17 00:39:58 +02:00
Marc-André Bombeck 393eef82d8 chore(release): v1.4.34.4
Audit-driven hotfix bundle. CHANGELOG carries the full notes — this
commit stamps the version and the changelog header so the release
pipeline picks it up.

This is the sixth release deployed under v1.4.34.2's pull_policy:
always — the deploy mechanic should pick up the image without any
host-side retag fallback.
2026-05-17 00:39:20 +02:00
Marc-André Bombeck 70788895fa docs(integrations): Apple Health, Withings, AI providers guides
Three end-to-end integration walkthroughs closing the docs gaps the
v1.4.34.3 docs-depth audit flagged as critical user-facing surfaces.

Apple Health (docs/integrations/apple-health.md, 200 lines):
  - Generating the iOS `export.zip` from the Health app.
  - 1.5 GB upload cap, Zip64-aware streaming parser, content-hash
    idempotency.
  - Per-record / per-workout / per-correlation / per-clinical-record
    coverage matrix grounded in the parser's source.
  - Cumulative HK-type daily aggregation contract
    (`stats:<HKType>:<YYYY-MM-DD>` external IDs).
  - Live status-polling endpoint and the admin-import variant.
  - Source-priority interaction with the existing Withings ladder.

Withings (docs/integrations/withings.md, 195 lines):
  - Developer-portal app registration with the right OAuth scopes
    (`user.metrics user.activity`) and redirect URI.
  - Env-var layout (`WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`,
    `WITHINGS_WEBHOOK_SECRET`).
  - Why the webhook URL carries the secret as a path segment
    (v1.4.25 W17a — reverse proxies log `query_string` by default;
    path segments aren't logged, so the secret stays off the access
    log).
  - Sync coverage (weight, body composition, BP, sleep, activity,
    SpO₂), reauth handling, troubleshooting.

AI providers (docs/integrations/ai-providers.md, 244 lines):
  - All four `User.aiProvider` values (OPENAI, ANTHROPIC, LOCAL,
    CHATGPT_OAUTH).
  - Default models, BYOK vs admin-shared, the provider fallback chain.
  - Local-endpoint setup with Ollama, LM Studio, and vLLM examples;
    SSRF-guard rationale; ChatGPT device-code OAuth pointer.
  - Privacy stance — local endpoints keep measurement context inside
    your network; hosted endpoints send the snapshot to the provider.
2026-05-17 00:37:05 +02:00
Marc-André Bombeck c81ddbdbc8 mobile: TopBar reserves safe-area-inset-top for iOS PWA notched iPhones
The sticky header was h-16 with no safe-area handling. On notched
iPhones (~44 px status-bar inset) the iOS PWA painted the status bar
over the header — the HealthLog logo + auth controls clipped under
the system clock. Adding the inset as inline padding-top reserves the
overlay without touching the 64 px content baseline.

Inline `style` over a Tailwind class because Tailwind doesn't expose
`pt-[env(safe-area-inset-top)]` without arbitrary-value escaping and
the inline form is clearer in the diff.
2026-05-17 00:36:10 +02:00
Marc-André Bombeck 26f9880264 test: update toJson mock factories for doctor-report-prefs + healthkit
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.

The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
2026-05-17 00:34:58 +02:00
Marc-André Bombeck be7f4aa92b mobile + docs: themeColor light-mode, Coach FAB safe-area, Sonner system theme, self-hosting docs
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.

Mobile-deep:
  - `viewport.themeColor` was pinned to `#282a36` (dark). The
    Android URL bar + iOS PWA status bar stayed dark even when the
    user was on the light theme. Replaced with the dual-media-query
    form: `#ffffff` under `(prefers-color-scheme: light)`,
    `#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
    now matches the active palette.
  - Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
    the home-indicator safe area is ~34 px so the FAB collided with
    the bottom-nav. Moved to
    `bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
    floats above the inset-aware nav.
  - Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
    page the toast contrasted badly against the surface. Switched to
    `theme="system"` so Sonner reads the resolved theme from the
    root element (set pre-paint by the inline theme script in
    layout.tsx) instead of pinning dark.

Self-hosting docs:
  - `docs/self-hosting/getting-started.md` — step-by-step from
    `git clone` to first measurement, covering the 3-secret env
    setup (post-SESSION_SECRET drop), `docker compose up -d`, the
    first-user-becomes-admin contract, and the next-step pointers.
  - `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
    Proxy Manager, Coolify, and bare-Nginx config examples. Closes
    the README link at line 107 that previously punted to the docs
    site.
2026-05-17 00:34:41 +02:00
Marc-André Bombeck 999fc257ae code-quality: drop dead SESSION_SECRET, add toJson helper, document db-compat, type passkey responses, lock zero-TODO
Five hotfix-ready findings from the v1.4.34.3 code-quality audit.

SESSION_SECRET drop (F-3):
  The env var was required by `docker-entrypoint.sh`, listed in
  `.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the
  scaling guide — but `grep -rn SESSION_SECRET src/` returned
  nothing. Sessions are server-stored in `prisma.session` keyed by
  the cookie value; the secret signed nothing. Every self-hoster
  carried a ceremonial 64-char hex string that the app ignored.
  Removed everywhere except the README (separate batch). Each fresh
  install now generates 3 secrets, not 4.

toJson helper (F-9):
  Seven sites cast Prisma JSON writes via `value as unknown as
  Prisma.InputJsonValue`. Each call re-invented the same escape
  hatch. Replaced with one helper in `src/lib/db.ts`:
  `export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
  Migrated all 7 call sites: `auth/me/doctor-report-prefs`,
  `dashboard/chart-overlay-prefs`, `dashboard/widgets`,
  `integrations/healthkit`, and 4 sites in
  `apple-health-import-worker`. The helper has a one-line WHY
  comment + a unit test pinning the cast shape stays stable.

db-compat docstring (F-10):
  `src/lib/db-compat.ts` looks like accumulated backwards-compat
  cruft on first read. It's actually the schema-bootstrap path that
  runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without
  `prisma migrate deploy`. Added a header docstring naming the role
  and pointing at `prisma/migrations/` as the canonical history.

Passkey types (F-11):
  `src/lib/auth/passkey.ts:103,169` used `response: any` with
  per-line `eslint-disable`. Imported `RegistrationResponseJSON` and
  `AuthenticationResponseJSON` from `@simplewebauthn/types` and
  dropped the disables.

Zero-TODO CI gate (F-15):
  The audit confirmed product code has zero TODO / FIXME / XXX /
  HACK markers — genuinely rare and worth preserving. Added a tiny
  GitHub Actions workflow at `.github/workflows/no-todo-markers.yml`
  that fails the build on any future re-introduction. Test files
  are excluded; product code only.
2026-05-17 00:34:21 +02:00
Marc-André Bombeck 1768b1d971 ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.

i18n leaks (3 primitives):
  - Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
    so a German user heard 'Close, button' on every modal. Both lift
    `useTranslations()` and read `common.close`.
  - ChartSkeleton's sr-only loading announcement was English-only.
    The component is already a client island; reads `charts.loadingLabel`
    from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
  - Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
    was hardcoded. Routes through `t("nav.breadcrumb")` with a new
    key in each locale.

Focus rings (mobile + tablet nav):
  - Bottom-nav primary links + the More overflow trigger relied on a
    `text-muted-foreground` → `text-primary` colour transition only;
    keyboard / switch-control users saw no focus indicator at all.
    Added `focus-visible:ring-ring/50 focus-visible:ring-2
    focus-visible:outline-none focus-visible:ring-offset-2` matching
    the v1.4.33 sidebar treatment.
  - Mobile top-bar trigger had `focus:outline-none` without a
    `focus-visible` replacement. Same treatment applied.

Tap-target floor (WCAG 2.5.5):
  - Input / NativeSelect / Select / DateTimeInput were locked to
    h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
    mobile clears the 44 px floor while desktop keeps the 40 px norm
    that the Button primitive already converged on in v1.4.33.

Admin chip-strip auto-scroll-into-view:
  - The admin shell shipped 13 chips but lacked the
    `scrollIntoView({inline: 'center'})` effect the settings shell
    got in v1.4.33. Admins tapping a chip past the right edge landed
    on the new page with the strip scrolled to the left and the
    active chip off-screen. Copied the settings-shell pattern verbatim.

List-page subtitles on mobile:
  - Measurements / Mood / Medications pages hid their descriptive
    subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
    the subtitle on mobile because it doubles as contextual help.
    Dropped the hide rule; the H1 still anchors the visual.

Insights empty-state CTA vocabulary:
  - `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
    `TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
    `MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
    empty-state CTAs emitted those mismatched tokens and the dialog
    silently failed to open. Replaced with a single source of truth
    derived from `MEASUREMENT_TYPES` plus a normalisation map for
    legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
    New unit test walks every `?add=` token emitted under
    `src/app/insights/**` and asserts it resolves to a real form type.
2026-05-17 00:34:00 +02:00
Marc-André Bombeck 6885703505 security: HSTS preload + Withings CSP gating + SW URL trust + manifest hygiene
HSTS gains `; preload` so the domain becomes eligible for the
Chromium preload list. Withings CSP host moves from global to a
path-gated entry (mirrors the existing AI-host gating) — closes a
DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns
once per process when `TRUST_PROXY_HOPS` and the XFF chain length
disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so
future callers can route to a tighter universal rate-limit bucket
when the chain looks tampered with.

Service worker now resolves push-notification payload URLs against
`self.location.origin` and rejects off-origin redirects (falls back
to `/`). The German-only offline fallback HTML body was replaced
with a language-neutral icon + 'Offline' wordmark so EN / FR / ES /
IT / PL users don't see German on a cold offline boot. Manifest
gained `"scope": "/"`, `"id": "/?source=pwa"`, and
`"display_override": ["standalone"]` so future sub-path deploys
don't accidentally inherit root scope and install-prompt heuristics
anchor on a stable id.

+16 new test cases across two new files covering HSTS preload, CSP
gating on/off, the once-per-process warning, the tagged helper's 4
shape branches, SW same-origin guard, offline-locale-neutrality, and
manifest hygiene. Existing proxy/SW suites stay green.
2026-05-17 00:33:34 +02:00
Marc-André Bombeck 46fe638741 docs(planning): A6 mobile-deep audit report (16 findings, 5 hotfix-ready) 2026-05-17 00:29:40 +02:00
Marc-André Bombeck e0fe7c0a3d docs: README discoverability rework + OG metadata + planning audit reports
README:
  - Add a "How it compares" matrix against Withings web, Apple Health,
    Oura, Garmin, and generic CSV. The audit found this section absent;
    self-hosters arrive comparing alternatives and bounce when there's
    no head-to-head.
  - Promote Apple Health import + AI Coach as standalone Key-Features
    bullets. Apple Health was the v1.4.34 banner feature with zero
    grep matches in the README until now; the audit ranked it the
    single highest-intent search keyword for ex-iOS users. AI Coach
    + Insights moved out from under "Multi-Provider AI Insights" into
    a dedicated bullet that names the Coach, Daily Briefing, Weekly
    Report, and Health Score surfaces.
  - Status block under "What it is" so visitors see release cadence
    + current focus (native iOS client) without trawling the changelog.
  - Badge row: drop "PRs welcome" + duplicate Self-Hosted, add a live
    "Latest release" badge + a GHCR multi-arch badge so the landing
    page surfaces version + container distribution at a glance.
  - Tagline tightened to lead with "self-hosted health tracker" + the
    two integration anchors (Withings + Apple Health) — both phrases
    are search-keyword fronts the GitHub search index picks up.
  - Drop the dead SESSION_SECRET env entry from Quick Start (3
    secrets, not 4) and the Environment Variables table — the env
    var was documented as required but never read by code (per the
    code-quality audit). Companion env-removal in docker-compose /
    .env.example / entrypoint ships in a parallel batch.
  - Add a one-line OpenAPI pointer above the API-Reference table so
    integrators discover the codegen source.

Layout / OG metadata:
  - openGraph + twitter cards now carry images, a higher-intent
    description, and a summary_large_image card. Uses the existing
    logo-readme.png as the OG asset; replace with a 1200x630 dashboard
    capture when one ships.

GitHub repo metadata (via gh CLI, no git diff):
  - description tightened from 290 chars to 156 chars, keyword-front.
  - homepage flipped from a personal tenant to demo.healthlog.dev so
    sidebar clicks land on a working demo instead of a login wall.
  - Topics: dropped 6 generic/PII-adjacent entries (glp-1, mounjaro,
    tracking, health, dashboard, bloodpressure), added 6 high-intent
    ones (apple-health-import, withings-alternative, glucose-tracker,
    mood-tracker, ai-insights, personal-dashboard).

Planning artifacts:
  - 7 audit reports drop into .planning/round-audit-*.md covering
    mobile-security, UX, code-quality, README/discoverability,
    perf, mobile-deep (pending), test-coverage, docs-depth. These
    are the working notes for the v1.4.34.4 audit-driven release.
2026-05-17 00:28:05 +02:00
Marc-André Bombeck 58c5416bc9 security: destroyAllSessions wipes API tokens + refresh tokens; tighten well-known proxy bypass
Two findings from the v1.4.34.3 security audit, both with the same
shape — every authenticated surface a user owns should rotate when a
credential-rotation path fires, and unfamiliar discovery sub-paths
must not inherit "no auth" status by default.

destroyAllSessions:
  Previously only ran `prisma.session.deleteMany`, leaving every
  long-lived `ApiToken` row (Bearer credentials issued via
  /settings/api-tokens) and every native-client `RefreshToken`
  intact. A stolen iOS Bearer kept working after the user rotated
  their password or after an admin reset; only the browser cookie
  flipped. Wraps the cleanup in a $transaction that flips
  `apiToken.revoked = true` and `refreshToken.revokedAt = now()` in
  the same atomic block so the audit trail survives. Both lookups at
  `src/lib/auth/api-token.ts` and `src/lib/auth/refresh-token.ts`
  already filter on these columns; no second code path needed.

  Same destroyAllSessions call already runs from:
    - POST /api/auth/password (user-initiated change)
    - POST /api/admin/users/[id]/reset-password (admin reset)
    - DELETE /api/settings/account (account deletion)

  All three paths now wipe every transport in one shot.

Well-known proxy bypass:
  `src/proxy.ts` PUBLIC_PATHS carried "/.well-known/" with a prefix
  match — a future route at `/.well-known/foo` would inherit "no
  auth" status silently. Tighten to an exact-match Set
  (WELL_KNOWN_PUBLIC_PATHS) seeded with the one path we actually
  serve (`apple-app-site-association`). New routes must be added
  explicitly.

  Also drops the stale `/api/auth/codex/callback` PUBLIC_PATHS
  entry — that route doesn't exist in the codebase (the Codex
  OAuth flow uses `/api/auth/codex/{device-poll,device-start,
  disconnect}` instead).

Tests:
  - New destroyAllSessions assertion in src/lib/auth/__tests__/
    session.test.ts pins the three updateMany/deleteMany call
    shapes inside a single $transaction.
  - Existing proxy well-known + privacy + onboarding redirect
    suites stay green.
2026-05-17 00:21:44 +02:00
Marc-André Bombeck 2ef7f04e89 v1.4.34.3 — remove the dashboard Coach CTA
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.
v1.4.34.3
2026-05-17 00:06:18 +02:00
Marc-André Bombeck 8886c0ffdf feat(dashboard): remove the hero Coach CTA
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.
2026-05-17 00:05:54 +02:00
Marc-André Bombeck 18b65ebc8b docs(planning): v1.4.34.1+.2 closure — pull_policy deploy-mechanic addendum 2026-05-16 23:23:27 +02:00
Marc-André Bombeck 1f57dc3808 v1.4.34.2 — pull_policy: always on docker-compose so Coolify deploys actually pull
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.
v1.4.34.2
2026-05-16 23:12:36 +02:00
Marc-André Bombeck 76128328ff chore(release): v1.4.34.2 — pull_policy: always on docker-compose
The v1.4.34.1 image published to GHCR successfully but never reached
production because Coolify's `docker compose up` kept reusing the
cached `:latest` digest on the apps-01 host. The default Docker pull
policy ("only pull if missing") skipped the registry round-trip and
restarted the container with the prior v1.4.34 image.

Declare `pull_policy: always` on the `app` service so compose-up
re-checks the registry digest on every `up` regardless of the local
cache. Bundle-release because the v1.4.34.1 work ships through the
same `:latest` tag — this is the deploy mechanic that makes v1.4.34.1
actually land.
2026-05-16 23:12:21 +02:00
Marc-André Bombeck ce1fba5839 docs(planning): v1.4.34.1 closure — Insights cold-mount perf hotfix 2026-05-16 22:30:53 +02:00
Marc-André Bombeck f6ca89b963 v1.4.34.1 — insights cold-mount perf hotfix + scatter-card sizing
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.
v1.4.34.1
2026-05-16 22:26:59 +02:00
Marc-André Bombeck c86ee1a636 chore(release): v1.4.34.1
Hotfix on top of v1.4.34 bundling the chart-card dimension fix and the
Insights cold-mount perf rewrite. CHANGELOG carries the full notes; this
commit just stamps the version + changelog header so the release
pipeline picks it up.
2026-05-16 22:25:33 +02:00
Marc-André Bombeck f535eb0e42 docs(planning): v1.4.34 IW reports + cache-aggregation research
Backfills the planning trail for v1.4.34: IW-C / IW-D / IW-G work-item
reports, the production slowness HAR investigation, the AASA rollout
trace from v1.4.33, and three pieces of research that informed the
follow-on perf work (XML import, carryover scope, cache aggregation).

The cache-aggregation research (research/v1434-r-cache-aggregation.md)
designed the per-route LRU pattern that IW-G shipped and is the
direct ancestor of the comprehensive-route rewrite this release lands.
2026-05-16 22:25:06 +02:00
Marc-André Bombeck 7bbcc872f8 perf(api): wire 6 remaining hot reads through the server cache
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.

Reads wrapped (60 s TTL unless noted):
  - GET /api/mood/analytics                 → caches.moodAnalytics, key userId
  - GET /api/workouts                       → caches.workouts, key includes
                                              all params (limit/offset/since/
                                              until/sportType)
  - GET /api/bugreport/status               → caches.bugreportStatus, key
                                              "singleton" (10 min TTL);
                                              isAdmin layered per request
  - GET /api/medications                    → caches.medications, key userId
  - GET /api/dashboard/widgets              → caches.dashboardWidgets, key
                                              userId (5 min TTL)

Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.

The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.

The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
2026-05-16 22:24:56 +02:00