Self-review polish on top of the W2 fast-path landings:
- Rename the misleading `ninetyFiveDaysAgo` local in
bp-in-target-fast-path to `readSince` so the 396-day BP rollup
read window does not read like a 95-day shortcut.
- Document the rollup-bucket-vs-display-tz day-key shift in
correlations-fast-path. The DAY bucket anchors at UTC midnight; for
the Berlin-centric production tenant that lands on the same local
calendar day, so the rollup-fast-path's per-day pair shape matches
the live path within the n >= 20 surface gate. A v1.5 follow-up can
mint per-user-tz buckets if a non-Berlin account needs strict
parity.
The generic medication card surfaces the upcoming schedule dose with
a `font-medium text-purple-400` accent so titration overrides on the
schedule visually pop out of the muted next-intake line. The GLP-1
card omitted the accent entirely.
Mirror the same accent on the GLP-1 card so the only difference
between Mounjaro and Ramipril detail surfaces is the data being
shown, not the styling treatment. The schedule.dose still rides
inside the existing next-injection paragraph (hidden below sm: like
the generic card so the narrow-viewport row stays one line).
GET /api/measurements gains two new branches:
* groupBy=day + cumulative type → returns one synthesised row per
user-TZ day with value=SUM, sampleCount, and a dayKey anchor so
the list view paints one row per day instead of hundreds of
per-sample chunks from Apple Health.
* dayKey=YYYY-MM-DD + cumulative type → returns the per-sample rows
for that single calendar day in the user's IANA timezone, so the
expand chevron in the row can lazy-fetch the chunks that summed
into the daily total.
Both branches gate on CUMULATIVE_HK_TYPES so the iOS contract for
spot metrics (BP, weight, pulse, BG) stays untouched. The collapsed
row's measuredAt anchors to local noon to match the
canonicalDailyTimestamp convention used by the drain helper.
/targets carries two Coach affordances — the per-card 'Ask Coach' CTA
on every TargetCard and the page-level CoachDrawer mount. The CTAs
were gated through `aiEnabled` (provider chain status), but the
drawer mount always rendered regardless of the operator's global
Coach flag. When the operator turned Coach off in Settings the drawer
still loaded its SSE machinery + Sheet portal subtree even though no
on-page CTA could open it.
Read flags.coach via useFeatureFlags and fold it into the existing
`aiEnabled` gate so the per-card CTAs vanish when either layer is
off. Wrap the page-level <CoachDrawer> mount in the same gate as
defence-in-depth, since the CTAs already hide when the flag is off.
Tests: add two new cases to targets-coach-mount.test.tsx covering the
Coach-flag-off branch (CTAs hide + drawer mount disappears). The
sibling targets-* test files mock @tanstack/react-query without
exposing QueryClientContext; add a use-feature-flags mock that
short-circuits to the all-on default so the existing assertions keep
running against the visible page shape.
Switch the title-row flex from items-start to items-center
sm:items-start. The dashboard.title plus welcomeText sub-line
wraps to two lines on < 380 px viewports and the button used to
hug the top of that 2-line block without a baseline anchor.
items-center on mobile centres the button against the title; sm+
returns to the original top-aligned posture where the title is
one line.
The doctor-report PDF is HealthLog's flagship "data out" path — the
artefact a user actually carries into a doctor's appointment. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid, which undersold the feature.
This change extracts the doctor-report flow into a new
`<ArztberichtHeroCard>` component and renders it at the top of
`/settings/export` with the same `hero-gradient + glow-purple` visual
treatment the Insights hero strip uses, so the surface feels
consistent with the rest of the app. The remaining four export cards
(Measurements CSV, Medications CSV, Mood CSV, Full JSON backup) move
under a dedicated "Weitere Export-Optionen" / "Other export options"
sub-heading.
The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first:
the hero stacks at < sm with the CTA falling onto its own row; the
button clears the 44 px WCAG touch floor (`h-11 sm:h-10`) and carries
an `aria-describedby` link to the value statement so screen readers
announce both alongside the action.
The Personal Health Score builder used to read 37 days of raw weight
rows from the measurements table on every dashboard mount. When DAY
rollup coverage exists for WEIGHT, the new helper derives the weight
series from per-day MEAN values in measurement_rollups and only
issues a narrow 2-column projection against measurements for the
source-attribution pill (the rollup table does not carry the source
enum). Partial-coverage and brand-new accounts keep the legacy 37-day
raw read so the score stays correct on first cold-mount.
Mood, medications, and intake events still read live regardless of
coverage — none has a rollup equivalent today. A path:"rollup"|
"live" annotate on meta.healthScore.path makes the branch selection
provable in prod logs, matching the bp_in_target and summaries-slice
patterns.
Body relocated from route.ts to lib/analytics/health-score-fast-path.ts
so the dispatcher can be unit-tested against mocked Prisma without a
container. Unit test pins both branches (probe → rollup, probe →
live), the empty-component-set early-out, and the implicit-probe
shortcut for callers that omit the coverage map.
Run prettier --write on the four files the v1.4.37 W3 commits
touched (the schema, the route, the V2 component, and the two test
files). Drive-by also collapses two pre-existing whitespace drifts
in the InventoryItem type aliases and the glp1PostBodySchema refine
call so the file matches the formatter contract end-to-end. No
behavioural change.
Extend the list endpoint with an optional `status` query knob —
`all` (default), `taken`, `skipped`, `completed` — so the detail-page
intake history can hide unconfirmed rows server-side without changing
the byte-stable contract the iOS Swift client and the existing
dashboard tiles depend on. `completed` (taken OR skipped) covers the
detail-page surface; `taken` / `skipped` are there for any future
single-arm consumer. Co-located route test pins the Prisma where
fragment per branch and the 422 on unknown values.
When DAY-bucket coverage exists for both BLOOD_PRESSURE_SYS and
BLOOD_PRESSURE_DIA, the /api/analytics default slice now derives the
five in-target windows (7d / 30d / all-time / prior month / prior
year) from rollup mean values instead of paging the trailing 365 days
of raw measurements. The new helper pairs SYS and DIA per-day means
by day key, runs the existing isBpReadingInTarget predicate, and
buckets days into the reporting windows. Partial-coverage and
brand-new accounts fall back to the legacy chunked aggregator so the
per-event pairing contract still holds where the rollup table cannot
guarantee parity. A path:"rollup"|"live" annotate on the meta dict
makes the branch selection visible in prod logs, matching the
v1.4.36 summaries-slice pattern.
The per-day-mean approximation is documented in the helper's header.
For typical multi-reading-per-day usage it lands within ~2 % of the
per-event count; outlier days mixing extreme readings can flip the
classification either way. The live fallback remains the per-event
source of truth.
The generate route caught `FeaturesPayloadTooLargeError` from the
raw-mode `extractFeatures` call and retried in aggregated mode. The
retry ran the same `enforceSizeGuard` from `features.ts`, so a
very large medication history plus multi-year context could blow past
the 5 MB ceiling a second time → the uncaught throw bubbled to the
apiHandler's generic 500 path.
The retry now sits in its own try/catch. On a second oversize the
route applies the existing `applyInsightsExcludeFilter` with the
optional context blocks (anthropometrics, medications, sleep, steps,
hrv, resting_hr) excluded and re-extracts. A third failure annotates
`insights_payload_too_large` for ops visibility and returns 422
with a readable user-facing message rather than 500. Unit test mocks
all three extractor calls to throw and asserts the 422 + annotate.
The v1.4.36 perf wave wrapped the briefing / correlation / trends
blocks in `<Suspense>` boundaries. The page is `"use client"`,
the blocks load via `next/dynamic({ ssr: false })` (no promise
throw on hydrate), and TanStack Query hooks never throw thenables, so
the boundaries never engaged — dead code, masking the real shape of
the delivery.
The actual perceptual win remains intact: the page-level
`isLoading` gate is gone, the hero plus each section's own
loader skeleton paints inside the first paint budget while data fills
in. A comment in the file explains why no `<Suspense>` wrapper
belongs here so a future contributor doesn't re-add one.
NOTE for CHANGELOG drafting: the perf-wave bullet should read
"early-skeleton paint" rather than "Suspense streaming".
Genuinely streamed server children remain a v1.5.x track.
Pulls the v1.4.36 W4c MeasurementType -> SourcePriorityMetricKey
chain out of the dashboard per-type loop into a typed switch helper.
Same call sites, same null fallback for the unmapped TIME_IN_DAYLIGHT
branch.
v1.4.36's perf wave added a `SELECT COUNT(*) FROM measurement_rollups`
probe at the head of `computeSummariesSlice`. The slim-slice route test
mocks `$queryRaw` with two `mockResolvedValueOnce` calls (aggregate +
latest); the new probe consumed the first slot, leaving the aggregate
read pointing at undefined and the route returning 500.
Seed `n: BigInt(0)` as the first mock value so the route deterministically
takes the legacy heavy-aggregate fallback path the rest of the test
expects.
The targets endpoint runs >1 s cold — pulls 30 days of measurements
for every type plus medications, intakes, mood and glucose to build
the consistency strips, streaks and target-met-this-week rollup. It's
read on every Insights mount and on every sub-page click, so the
cumulative cost dominates a power user's session.
Wrap the body in the existing analytics-style server cache with a 60 s
TTL and a per-user key. The invalidation helpers for measurement,
mood and medication writes evict the user bucket so the next read
paints fresh data without the cache lagging the underlying state.
The bfcache-friendly Cache-Control header lets a back-forward
navigation restore from the BFCache instead of repaying the cost.
The About surface (version + license + source links + update check)
used to live as its own Settings section and the user-card dropdown
exposed it from both desktop and mobile. The three cards collectively
get opened once or twice a year, mostly by the operator confirming
the running version against the latest release.
Move the surface to `/admin/about` and drop the dropdown link from
both the desktop sidebar and the mobile top-bar. The public-facing
landing at `/about` (credits + MaxMind attribution) stays untouched
since it's reachable without a session. The legacy `/settings/about`
route still resolves so external bookmarks don't break, but is no
longer surfaced in any nav.
The existing `<AboutSection>` is reused as-is — no markup changes,
the component owns its own heading so the admin renderer mounts it
without the SectionFrame wrapper.
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.
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").
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.
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.
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.
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)
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.
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.
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
from the DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets.
A defensive parity check against the live aggregate's COUNT(*) falls
back to live SQL when divergence is detected: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.
Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.
tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.
Schema:
- new MeasurementRollup model with composite PK
(userId, type, granularity, bucketStart) and a descending
bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
- Prisma migration 0067_v1434_measurement_rollups is additive:
CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
- db-compat schema-bootstrap path mirrored.
Populator (src/lib/measurements/rollups.ts):
- recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
pg-boss (singleton-keyed).
- recomputeUserRollups: bounded full-range, used by Apple Health
import + backfill.
- ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
recompute. Error-swallowing.
- SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
unit (Postgres 42803 forbids binding the unit literal).
Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.
Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).
Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.
Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.
Read-swap follow-up:
- comprehensive-aggregator + analytics/route + summaries-slice
each call ensureUserRollupsFresh as a read side-effect (warms
the table). The response stays on the live-SQL aggregator so
byte-shape parity with v1.4.34.5 is preserved.
- Next commit lands the partial read-swap: count/min/max/mean
and dailyByType from buckets (math-identical compose), while
slope/r2/sd stay on indexed live aggregation. Together they
ship as v1.4.35.
Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
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.
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.
The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.
Mobile-deep:
- `viewport.themeColor` was pinned to `#282a36` (dark). The
Android URL bar + iOS PWA status bar stayed dark even when the
user was on the light theme. Replaced with the dual-media-query
form: `#ffffff` under `(prefers-color-scheme: light)`,
`#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
now matches the active palette.
- Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
the home-indicator safe area is ~34 px so the FAB collided with
the bottom-nav. Moved to
`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
floats above the inset-aware nav.
- Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
page the toast contrasted badly against the surface. Switched to
`theme="system"` so Sonner reads the resolved theme from the
root element (set pre-paint by the inline theme script in
layout.tsx) instead of pinning dark.
Self-hosting docs:
- `docs/self-hosting/getting-started.md` — step-by-step from
`git clone` to first measurement, covering the 3-secret env
setup (post-SESSION_SECRET drop), `docker compose up -d`, the
first-user-becomes-admin contract, and the next-step pointers.
- `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
Proxy Manager, Coolify, and bare-Nginx config examples. Closes
the README link at line 107 that previously punted to the docs
site.
Five hotfix-ready findings from the v1.4.34.3 code-quality audit.
SESSION_SECRET drop (F-3):
The env var was required by `docker-entrypoint.sh`, listed in
`.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the
scaling guide — but `grep -rn SESSION_SECRET src/` returned
nothing. Sessions are server-stored in `prisma.session` keyed by
the cookie value; the secret signed nothing. Every self-hoster
carried a ceremonial 64-char hex string that the app ignored.
Removed everywhere except the README (separate batch). Each fresh
install now generates 3 secrets, not 4.
toJson helper (F-9):
Seven sites cast Prisma JSON writes via `value as unknown as
Prisma.InputJsonValue`. Each call re-invented the same escape
hatch. Replaced with one helper in `src/lib/db.ts`:
`export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
Migrated all 7 call sites: `auth/me/doctor-report-prefs`,
`dashboard/chart-overlay-prefs`, `dashboard/widgets`,
`integrations/healthkit`, and 4 sites in
`apple-health-import-worker`. The helper has a one-line WHY
comment + a unit test pinning the cast shape stays stable.
db-compat docstring (F-10):
`src/lib/db-compat.ts` looks like accumulated backwards-compat
cruft on first read. It's actually the schema-bootstrap path that
runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without
`prisma migrate deploy`. Added a header docstring naming the role
and pointing at `prisma/migrations/` as the canonical history.
Passkey types (F-11):
`src/lib/auth/passkey.ts:103,169` used `response: any` with
per-line `eslint-disable`. Imported `RegistrationResponseJSON` and
`AuthenticationResponseJSON` from `@simplewebauthn/types` and
dropped the disables.
Zero-TODO CI gate (F-15):
The audit confirmed product code has zero TODO / FIXME / XXX /
HACK markers — genuinely rare and worth preserving. Added a tiny
GitHub Actions workflow at `.github/workflows/no-todo-markers.yml`
that fails the build on any future re-introduction. Test files
are excluded; product code only.
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.
i18n leaks (3 primitives):
- Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
so a German user heard 'Close, button' on every modal. Both lift
`useTranslations()` and read `common.close`.
- ChartSkeleton's sr-only loading announcement was English-only.
The component is already a client island; reads `charts.loadingLabel`
from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
- Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
was hardcoded. Routes through `t("nav.breadcrumb")` with a new
key in each locale.
Focus rings (mobile + tablet nav):
- Bottom-nav primary links + the More overflow trigger relied on a
`text-muted-foreground` → `text-primary` colour transition only;
keyboard / switch-control users saw no focus indicator at all.
Added `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2` matching
the v1.4.33 sidebar treatment.
- Mobile top-bar trigger had `focus:outline-none` without a
`focus-visible` replacement. Same treatment applied.
Tap-target floor (WCAG 2.5.5):
- Input / NativeSelect / Select / DateTimeInput were locked to
h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
mobile clears the 44 px floor while desktop keeps the 40 px norm
that the Button primitive already converged on in v1.4.33.
Admin chip-strip auto-scroll-into-view:
- The admin shell shipped 13 chips but lacked the
`scrollIntoView({inline: 'center'})` effect the settings shell
got in v1.4.33. Admins tapping a chip past the right edge landed
on the new page with the strip scrolled to the left and the
active chip off-screen. Copied the settings-shell pattern verbatim.
List-page subtitles on mobile:
- Measurements / Mood / Medications pages hid their descriptive
subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
the subtitle on mobile because it doubles as contextual help.
Dropped the hide rule; the H1 still anchors the visual.
Insights empty-state CTA vocabulary:
- `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
`TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
`MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
empty-state CTAs emitted those mismatched tokens and the dialog
silently failed to open. Replaced with a single source of truth
derived from `MEASUREMENT_TYPES` plus a normalisation map for
legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
New unit test walks every `?add=` token emitted under
`src/app/insights/**` and asserts it resolves to a real form type.
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.
Per maintainer directive — the v1.4.34 IW-B "Frag den Coach" sparkles
button sat next to the "Hinzufügen" dropdown on the dashboard hero and
read as visual noise rather than a useful surface. Remove it; the
Coach drawer remains reachable from the /insights tree (its original
home), and the auth-shell `<CoachLaunchProvider>` + drawer mount stay
intact so any future page that wants its own opt-in CTA can call
`askCoach()` without churn.
The hook usages this leaves orphaned in `src/app/page.tsx`:
- `useCoachLaunch` (only consumer was the CTA)
- `useFeatureFlags` (only gate was `featureFlags.coach`)
- `Sparkles` icon import
All drop out cleanly.
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.
Reads wrapped (60 s TTL unless noted):
- GET /api/mood/analytics → caches.moodAnalytics, key userId
- GET /api/workouts → caches.workouts, key includes
all params (limit/offset/since/
until/sportType)
- GET /api/bugreport/status → caches.bugreportStatus, key
"singleton" (10 min TTL);
isAdmin layered per request
- GET /api/medications → caches.medications, key userId
- GET /api/dashboard/widgets → caches.dashboardWidgets, key
userId (5 min TTL)
Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.
The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.
The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
The /api/insights/comprehensive handler used to walk every measurement
of every type from the trailing 90-day window into JS, then ran
summarize(), BMI, BP classification, target adherence, and four Pearson
correlations against the same pile of rows. Post Apple Health import the
unbounded findMany routinely returned 100 000+ rows, dominated request
time (~29 s in a production HAR capture), and starved the connection
pool — sibling endpoints cascaded into Cloudflare 503s.
Replace with a SQL-side aggregator in
src/lib/insights/comprehensive-aggregator.ts:
- Per-type DataSummary built via $queryRaw with AVG / MIN / MAX /
STDDEV_POP / REGR_SLOPE / REGR_R2 on the
measurements (user_id, type, measured_at) index. Anomaly count uses
STDDEV_POP to match the JS divisor (n, not n-1) and the same
round-then-threshold ordering as summarize().
- DISTINCT ON (type) for the latest raw value per type.
- date_trunc('day') daily means for correlation pairings.
- Two bounded findMany for BP sys/dia raw rows so the 5-minute
pairByTimestamp contract for bpPctInTarget survives byte-identical.
- One conditional MIN(measured_at) for dataSpanDays.
Route handler split into a buildComprehensiveResponse() helper wrapped
through caches.analytics (60 s TTL) keyed on `${userId}|comprehensive`.
The 60 s TTL converts every consumer within the window — page mount,
Coach drawer, recommendations grid — to a Map lookup; cold path drops
to a few hundred ms against fixture data matching the production volume
that triggered the bug.
One sanctioned semantic divergence: weight × BP correlation switches
from pairByTimestamp(24 h tolerance) to a daily-key join. The directive
authorised the swap for parity with the daily-mean shape mood × X
correlations already used; consumers comparing exact r/n against legacy
output may observe small drift.
Unit + integration tests cover the aggregator shape, cache hit / miss
counters, wide-event annotations, and end-to-end envelope parity.
Three integration tests against the real Postgres container pin the
v1.4.34 IW-G contract:
1. Ten concurrent /api/analytics reads against a cold cache fan into
a single builder call (misses === 1; remaining 9 split between
stampede joiners and warm-cache hits, with no second miss).
2. Priming analytics then writing a measurement evicts the cache —
the next read misses again and the response reflects the new row.
3. Compliance buckets re-read after an intake POST flush the cache
and surface the new taken-count.
Drops the unused _request parameter from buildAnalyticsResponse() so
the analytics route lints clean. CHANGELOG carries one Added line per
the marathon's user-facing voice rules.
Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.
Endpoints wired (per blueprint §6 matrix):
POST/DELETE /api/measurements
PUT/DELETE /api/measurements/[id]
POST /api/measurements/batch
DELETE /api/measurements/by-external-ids
POST/PUT/DELETE /api/mood-entries[/id]
POST /api/mood-entries/bulk
POST/PUT/DELETE /api/medications[/id]
POST /api/medications/[id]/intake
PUT/DELETE /api/medications/[id]/intake/[eventId]
POST /api/medications/intake/bulk
POST /api/workouts/batch
PUT/DELETE /api/dashboard/widgets
PUT /api/admin/settings (bug-report toggle path)
Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
/api/analytics — wraps both the slim (?slice=summaries) and default
slice paths through caches.analytics, keyed on ${userId}|${slice}.
TTL 60s. The default builder lifts into buildAnalyticsResponse() so
the cache miss / hit boundary stays at the route entry.
/api/gamification/achievements — wraps the locale-agnostic web result
through caches.achievements keyed on userId. TTL 60s. The iOS-format
locale transform runs after the cache read so the cache stays format-
neutral and the dashboard's duplicate mount (HAR-captured) coalesces
through single-flight.
/api/medications/intake?scope=compliance — wraps the per-day
compliance bucket build through caches.medicationsIntake keyed on
${userId}|compliance|${days}|${userTz}. TTL 15min because daily
buckets are slow-moving. The POST handler invalidates the per-user
medications/compliance/achievement caches after committing the dose
status change.
Each cached() call hands annotate() to the wrapper so cache.<name>.
outcome (hit|miss|stampede) + cache.<name>.key_hash land on every
request's wide event.
The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.
Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
`src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
stays scoped to `/insights/**` because the floating action would distract
from the dashboard hero on surfaces with a contextual inline CTA.
Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
the button silently disappears when the Coach surface is disabled.
The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.
`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.
`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.
The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.
Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
Adds 36 unit + contract tests across 7 files:
* `import-apple-health-export.test.ts` — end-to-end parse of a tiny
hand-authored fixture (spot weight, cumulative steps fold, sleep
stage, workout with sport mapping, deferred + unknown identifiers,
clinical record skip). Re-import idempotency: a second run reports
0 inserts + N updates. Memory ceiling: synthetic 10 000-record
export keeps heap delta under 100 MB.
* `hk-workout-activity-type-map.test.ts` — every value referenced
in the map exists in the canonical `workoutSportTypeEnum` union;
the iOS 18 canonical roster is covered; unknown identifiers fall
through to `"other"` with a falsy `known` flag.
* `unzip-export-xml.test.ts` — central-directory walker round-trips a
hand-built single-entry deflate ZIP; extractor throws on a missing
`export.xml` member.
* `stream-to-disk.test.ts` — boundary parser; named-field capture +
SHA-256 + text-field collection; size-cap enforcement; boundary-
missing error path.
* Route tests — auth gate (401), 403 on non-admin to the admin
variant, 413 on content-length cap, 429 on rate-limit; status
endpoint admits owner + triggering admin and 404s otherwise.
v1.4.34 IW-F-Perf — three changes.
1) New `use-achievements-query.test.tsx` mounts three consumers under
one TanStack QueryClient and asserts the shared queryKey collapses
them into a single network call. Pre-fix the notifier issued its
own request alongside the card; the regression guard surfaces it
immediately if a future drift reintroduces a per-user
discriminator.
2) Refresh `recent-achievements-card.test.tsx` and
`achievements/__tests__/page.test.tsx` to mock the new shared hook
directly instead of the raw `@tanstack/react-query` `useQuery`
shim. The two test suites stay scoped to render-shape, not the
network plumbing.
3) Capture the `/api/analytics` slim-vs-thick audit in
`.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
thick; the checklist is correctly slim by IW2 design but is
mounted inside the dashboard render tree so its slim call
duplicates the thick one already in flight. Documented two clean
migration paths and flagged the work for v1.4.34.x behind IW-B's
in-flight slim-slice routing.
v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.
Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
Three new routes wire the streaming parser to the wire:
* `POST /api/import/apple-health-export` — user-facing kick-off.
Streams the multipart upload straight to `/tmp` (no in-memory
buffer for the 1.5 GB hard cap), hashes the bytes inline for
content-based idempotency, creates an `ImportJob` row in
`queued`, and enqueues the `apple-health-import` pg-boss job.
Returns `202 { jobId }` so the client can poll for progress.
* `GET /api/import/apple-health-export/[jobId]/status` — polling
endpoint returning the canonical `ImportJobStatusResponse`
envelope (R-1 §8). Authorises on owner-id OR triggering admin.
* `POST /api/admin/import-apple-health-export` — admin variant
(cookie-only via `requireAdmin()`; Bearer never elevates) with
a `userId` text field naming the target user. Same content-hash
idempotency scoped per target user.
The kick-off path uses a hand-rolled multipart boundary walker
(`src/lib/multipart/stream-to-disk.ts`) so a multi-gigabyte
`export.zip` upload never lands in V8 heap — Next.js's
`request.formData()` buffers the full body. Captured bytes
stream to disk while a SHA-256 hash accumulates in the same pass.
Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3, §5.4,
§5.5, §9.
The dashboard tile strip needs to render an "Letzter Wert vor Xd" caption on
tiles whose latest reading is older than a week so a metric the user hasn't
logged in a while keeps its tile visible (with an explicit how-old hint)
instead of disappearing from the strip.
- `/api/analytics` default path captures the freshest `measuredAt` per
`MeasurementType` in the existing per-type chunked walk and surfaces it as
`lastSeenByType[type] = { lastSeenAt, daysAgo } | null`. Types the user
never logged report `null`.
- `?slice=summaries` (slim slice) carries the same field — the DISTINCT ON
pass already orders by `measured_at DESC` to pick `latest.value`; adding
`measured_at` to the projection costs zero round-trips.
- Both responses stamp `Cache-Control: private, max-age=0, must-revalidate`
(the typed `NO_STORE_BUT_BFCACHE` constant) so back-forward navigation can
restore the dashboard from bfcache instead of paying a full reload. The
framework default for cookie-touching routes is `no-store, must-revalidate`,
which Chromium counts as a hard bfcache breaker.
- `AnalyticsRawPayload` gains an optional `lastSeenByType` field so call
sites read the shape with type-safety; consumers that don't care stay
unchanged.
Three new test cases pin the contract: default-path freshness map keyed on
the chunked reader, slim-slice freshness map keyed on the DISTINCT ON pass,
and the bfcache-friendly Cache-Control header on the wire for both slices.
The medication compliance route and the achievement perfect-day
heuristic both bucket the classifier output into `on_time` / `late` /
`very_late`. After the classifier started emitting `early`, both
paths would have silently dropped those doses into `very_late`
(via the trailing `else`). Treat `early` as compliant alongside
`on_time` so the heatmap stays green and the perfect-day streak
survives a proactive logger. The compliance route also surfaces a
dedicated `early` count on `DailyComplianceEntry` for any future
consumer that wants to break it out from `on_time`.
Updates the cadence-pairing docstring to reflect the widened grace.
Adds a Next.js Route Handler at `/.well-known/apple-app-site-association`
that answers 200 with `application/json` on every host that fronts the
main app — both `healthlog.bombeck.io` (maintainer prod) and
`demo.healthlog.dev` (demo) share this handler since they ride the same
standalone runtime. The body advertises the iOS bundle's App ID prefix
(`S8WDX4W5KX.dev.healthlog.app`) under `webcredentials.apps` so the
passkey ceremony shares cleanly between the web origin and the iOS app —
the same ceremony that today exists at `/api/auth/passkey/*` on both
domains.
The `applinks.details` array stays empty for now; Universal Links are
out of scope for the first cut and pick up later once the iOS side opts
in to deep-linking specific routes.
Pairs with the proxy `/.well-known/` allowlist entry — without that
bypass the auth gate would 307 Apple's CDN fetch to `/auth/login`
before the handler ran.
The page now resolves CorrelationRow and TrendsRow through dynamic()
imports rather than top-level `from "..."` statements; the static-source
scan accepts either spelling so a future revert to eager imports still
flags the load-bearing module path.
The privacy + about pages constrain their main column to `max-w-3xl`
(768 px) while the rest of the app uses `max-w-screen-xl` (1280 px).
That's a deliberate readability choice -- long-form legal text is
easier to read at 70-80 chars per line -- but the convention wasn't
documented inline, so a future contributor could see the deviation
from the shell width as a stale token and "fix" it.
Inline rationale comment on both `<main>` blocks per the audit
finding §3.4. No layout change.
Lighthouse v1.4.33 flagged ~463 KiB of unused JS on first /insights
paint plus ~211 KiB of bundled JS as unminified. Two coordinated
moves close the gap without touching the Coach panel internals or
the dashboard tree.
next.config.ts
- `compiler.removeConsole` with `exclude: ["error", "warn"]` strips
the chart-wiring + Coach SSE breadcrumb literals from the prod
bundle. Error + warn paths still flow through to GlitchTip.
- `headers()` advertises `Permissions-Policy: unload=()` so Chromium
admits the page to the back/forward cache when navigating away. We
carry no `beforeunload` / `unload` listeners in-tree so the hint is
honest.
src/app/insights/page.tsx
- `<DailyBriefing>`, `<CorrelationRow>`, `<TrendsRow>` move behind
`next/dynamic({ ssr: false, loading: <Skeleton /> })`. All three
paint below the hero on every viewport and were eagerly imported
before. Each loader skeleton matches the dimensions of the resolved
block so layout doesn't shift.
- `<HeroStrip>` stays an eager import — the greeting + health-score
badge are the visible-first-paint surface.