Add a 'What the Coach remembers' settings section: lists the user's
durable facts grouped by category with a learned-when stamp, forgets a
single fact or clears all (behind a confirm), and notes the rolling
conversation summary. Consumes the v1.11.1 CoachFact routes via the
query-key factory; gated on the Coach feature; localised in all six
locales. Closes the API-only gap from v1.11.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
Add a Sharing section under Settings (between API and Export) where the owner
creates a read-only clinician share link, sees their active and inactive
links, and revokes one. Create wires to the C4 lifecycle API: it sends the
label, a rolling history window, an expiry (capped at 90 days, validated
before the round trip), an optional scoped FHIR-API toggle, and the FHIR
resource types. The raw token is revealed exactly once on create as a
copy-able `/c/<token>` URL; the list only ever carries the server's safe
projection (no token, since only its hash is stored). Revoke confirms through
an alert dialog and invalidates the list.
RSC page mounts a client island; reads unwrap the envelope and key off the
centralised query factory. New `settings.sharing` / `settings.sections.sharing`
strings land in all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a calm "your week / month in review" summary to the Insights
overview, generated from the period-narrative context (deltas, personal
bands, FDR-controlled associations, coincident days).
- New insight_narratives table (migration 0113): one durable row per
(user, period, locale); generated prose AES-256-GCM at rest following
the coach-message precedent; labels-only provenance stays plaintext.
- generatePeriodNarrative feeds the context into the provider chain with
a tight, descriptive-never-causal prompt; an insufficient context
yields no narrative, a missing provider no-ops, neither fabricates.
- Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated
per user; the read-only GET enqueues a single-user warm on a cold read.
- Read route is stale-while-revalidate and never blocks on the provider;
the card renders plain text (no markdown), a single provenance
disclosure, and a fixed footprint (no CLS). i18n in all six locales.
The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.
Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
Two parallel loaders for the generic derived-metric route had grown — a
components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a
3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix
with different tuple shapes, which silently poisons the TanStack cache between
surfaces. Collapse to the single components-local hook (auth-gated, 8 s client
ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is
always present so the metric prefix can no longer carry two shapes.
Add a timeline of the health notifications the user's device produced on
its own, reachable from the Insights overview. The surface reflects the
device's own decision: each row reads "Your device flagged …" and a
permanent disclaimer states this is not a medical assessment by
HealthLog and that HealthLog does not diagnose any condition.
The surface is data-availability-gated — it un-mounts entirely when the
user has no such events, so an account that never triggered one never
sees an empty or alarming card. Copy renders as plain React text
children; no markdown library is involved.
Resolve the overlap where the vitals tier and the composite tier both extend
the derived-metric registry, dispatch, query-key factory, i18n bundles and the
OpenAPI contract: every derived metric (vitals baseline, fitness/vascular age,
HRV balance, BMI, sleep score, readiness, coincident-deviation) is now wired and
implemented, the dispatch test asserts the full set, and the locale bundles carry
both the vitals and the score-anatomy/composite strings.
Build the composite tier of the derived-metrics layer on the shared seam:
- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
consistency, timing and composition sub-scores over the per-stage sleep
rows, reweighting around any sub-score the data cannot support (a legacy
asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
deviation-from-baseline components; returns insufficient below two present
components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
the latest day and fires when two or more coincide, listing the
contributing vitals as possible factors, never a cause or a diagnosis.
Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.
Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.
Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.
Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
Build the Apple-Health-Highlights grid of personal-vital tiles on the
Insights overview, backed by four new pure derived-metric compute
functions over the established seam:
- cardio-fitness band ("Fitness Age" frame) — VO2max placed against the
FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never
re-derived.
- vascular-age delta — Withings vascular-age vs chronological age, a
relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical
claim.
- HRV (SDNN) balance — reuses the typical-range engine for the band and
places the recent average as balanced / above / below; SDNN never
relabelled RMSSD; cadence-gated.
- BMI from weight + height — exact WHO classification, the fallback when
no device BMI exists.
Each metric carries coverage + confidence + provenance and cites its
standard through the provenance explainer. The dashboard renders one
SparklineDeltaTile per available vital: an absent vital does not render,
a still-building one shows the honest coverage meter rather than a
headline from too little history. Tiles read the generic
/api/insights/derived route through a TanStack Query hook keyed off the
centralised factory.
Regenerates the OpenAPI contract for the two new metric ids and adds the
vitals copy across all six locales.
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.
New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.
The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.
Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.
Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
Add a parameterised query key (insightsMetricStatus) and a
useInsightMetricStatus hook that reads the generic per-metric
assessment route GET /api/insights/metric-status?metric=<id>. The
seven bespoke status routes keep their own keys and hook union; this
one covers the HealthKit metric sub-pages that share a single route
keyed by the metric id. The hook mirrors the existing status loader:
8 s client ceiling, no inline retry, and the bounded preparing poll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f)
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.
The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.
Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
Add the data-value features to the insights category pages on top of the
density pass:
- Stat strip: lead each metric page with min / max / median / mean,
read from the already-fetched analytics summaries slice (no new
network). Median is now computed in both summary producers — exact in
the live path, a windowed PERCENTILE_CONT in the rollup-tier slim
slice. The thin HealthKit pages mount it too for parity; no AI
assessment is added there.
- Range band: shade an Apple-Health-style min-max area around the daily
mean line where the data supports it (non-cumulative metrics). The
daily-aggregate read now emits per-day min / max from the rollup
buckets; the band rides the existing target-range toggle.
- All readings: a dedicated per-metric subpage at /insights/values/<type>
reusing the measurement list pinned to one type (pagination + inline
edit/delete). A clear entry button mounts at the foot of each
category page.
- Diversity nudge: a gentle hint when a metric's readings cluster on one
weekday or time of day, computed from a bounded, cached timestamp
window — non-blocking, self-gating.
All new reads stay cache-friendly and off the navigation path.
Add the Category -> Tag taxonomy the mood surface was missing and the
free-text note the web form could never write, then surface both on the
Insights mood page.
Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog
plus a MoodEntryTagLink join, seeded with a default five-category set
(i18n-keyed labels, Lucide icons). Purely additive — the legacy flat
MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram /
MoodLog sync are untouched; an entry can carry both axes.
Capture: the mood-logging form and the edit sheet gain a Textarea for
the note (model + API already accepted it) and an icon-chip picker over
the taxonomy catalog (GET /api/mood/tags). Create / update resolve the
picked keys to links; unknown keys drop silently against the catalog.
Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and
a notes timeline join the existing distribution / weekday / flat-tag
sections. The aggregate extension also fixes the flat-tag axis, which
read the raw JSON string and so never populated.
i18n across all six locales. Tests cover the taxonomy aggregation, the
note round-trip, the structured-tag schema, and the notes timeline.
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.
- Schema: add medications.track_injection_sites + allowed_injection_sites
and users.global_excluded_injection_sites (migrations 0099, 0100;
additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
status-toggle, and bulk intake routes. Persist it only on a taken
injection with tracking enabled, validated against the medication's
effective allowed set (per-med allowed sites minus the user-level
global exclusion, deny wins) — a disallowed site returns 422 (the bulk
route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
wizard step, a skippable post-dose site prompt on both medication
cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.
The site capture is always optional and never blocks logging a dose.
Lift the mood aggregates the AI status generator already computes —
tag frequency + per-tag average, in-target share, and the
mood × metric Pearson correlations — into a shared
`mood-aggregates.ts` so the prose and the new charts read off one
implementation. `mood-status.ts` now consumes the shared helpers
instead of its own inline copies.
Add a cached `/api/mood/insights` endpoint that returns the
pre-computed shapes (summary, heatmap, distribution, weekday, tags,
correlations) behind a 60-second server cache and a bounded 365-day
read — a cheap read-only path, never a synchronous model call. The
bucket invalidates alongside the mood-analytics cache on a mood write.
Render the shapes on the mood Insights page:
- a calendar heatmap forked from the compliance heatmap, coloured by
the same mood-score bands as the line chart, with an adaptive
window (30 days until ~90 days of history, then 90, then a full
year past 180 days) so the grid is never mostly empty;
- a mood distribution bar chart (share of days per level);
- average mood by weekday with a best/toughest-day callout;
- a tag / trigger breakdown (frequency bars + per-tag average);
- mood vs. sleep / activity / pulse correlation cards reusing the
existing scatter chart.
Add the supporting i18n keys across all six locales.
A bare ["medications", id, "api-endpoint"] tuple escaped the
factory rule via useMemo; the new medicationApiEndpoint key in
the factory restores the single source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.
`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.
`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.
Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.
Waves shipped (each with a phase report under .planning/):
- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
integration contracts close audit Critical Finding #3 (soft-delete
invisibility) across dashboard, analytics, summaries, rollup tiers,
and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
/api/insights/{features,targets,cards} routes plus the
compliance-rollup hook gap closure for bulk-projection mints; the
post-QA reconcile patch closes the six remaining measurement.findMany
sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
queryKey factory-bypass walker test (extended to comparison/ in the
reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
time-sensitive APNs payload (priority 10 + interruption-level)
scoped via a parameterised test that pins all 6 other event-types
do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
append-only invariant pinned in test; revoke-then-regrant leaves
both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
(week/month/year) wired through summaries-slice and
health-score-fast-path with a linear-composability parity test
pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
eliminates a sibling-drift risk; knip whitelist trimmed to three
files pending follow-up.
QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):
- senior C1 — dashboard-suspense-boundaries regex aligned with the
post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
compliance recompute on both /api/medications/intake and
/api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).
Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.
User-facing prose: see CHANGELOG.md.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
Six MEDs grouped per the W5 reconcile brief:
- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
cross-site flow depends on it; sibling `healthlog_session` stays
`Lax` for the Withings OAuth callback).
- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
`startsWith("/onboarding")` to exact path + subroute guard
(`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
A future `/onboarding-export` route can no longer inherit the
silent-pass-through. Same guard mirrored on the redirect short-
circuit. Two new proxy tests pin the contract.
- Code-MED-3: lift `berlinDayKey()` from
`src/app/api/analytics/route.ts` to a shared
`src/lib/analytics/berlin-day.ts` and route the targets
sparkline-bucketing through it. The targets route used to bucket
by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
edge cases.
- Code-MED (streaming race): on slow connections SSE `done` fires
before the invalidate-refetch resolves, producing a 200-500ms
duplicate-bubble render. Add a 150ms grace window on the
message-thread that suppresses the persisted twin while the
streaming bubble is still in flight.
- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
`insights.coach.settingsTooltip` (W3 removed the cog; the keys
had zero call sites and would carry confusing legacy if v1.4.23's
prompt-tuning panel picks a different key shape).
- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
in `queryKeys.dashboardWidgets()` and migrate the three call
sites (dashboard, insights, settings/dashboard-layout-section).
Same query-key-collision discipline as `queryKeys.analytics()`.
Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /insights page rendered only the text-only InsightStatusCard per
section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence
meter), B5e (thumbs feedback) and the broader B1b polish from users.
Wave-D design review flagged this as CRITICAL C1.
Wires the InsightAdvisorCard component into the live route, consuming
the existing /api/insights/generate POST. The route already returns the
24h-cached payload without burning a rate-limit token, so a single
POST-without-force is functionally a GET-or-generate — no new API
surface needed (the reconcile report deferred C1 because it assumed
otherwise).
The cache is shared with the dashboard preview via the new
queryKeys.insightsAdvisor() so a regenerate on either surface refreshes
the other without a second LLM call.
Per-section InsightStatusCards stay below as supplemental detail; the
hero now also wires its regenerate button to the same handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX
Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.
- i18n: add 21 missing translation keys (targets.* namespace was only
partially populated after the English-first migration; common.reset and
settings.configured were referenced but absent), plus common helpers
(copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
measurement/mood/medication-dependent key sets. Wire them into
measurement-form/list, mood-form/list, medication-form/card,
intake-history-list, intake-timeline, medications page, and Withings
sync in settings. Fixes dashboard not auto-refreshing after adding a
measurement (old code only invalidated ["measurements"], not
["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
messages via sonner toasts and invoke getApiErrorMessage() so saves
fail loudly instead of silently. Success also gets a toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(i18n): package B — locale-aware formatters throughout
Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.
New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
dateTime, time, dateWithWeekday, monthShort. Also exports
parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
locale, memoized per render.
Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)
Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.
Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
Accept-Language) and sets <html lang> server-side, avoiding an
EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
SSR pass is correct.
Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
translator + locale. All section titles, column headers, BMI / BP
classifications, mood labels, and the footer disclaimer are
i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ux): package C — typed passkey errors, bug-report gating, error boundary
Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.
Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
to specific i18n keys instead of swallowing every failure as
"cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
now, so origin mismatches and previous-registration collisions show
actionable copy.
- 5 new passkey.* i18n keys in EN + DE.
Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
isAdmin}.
- /bugreport page now queries status first. When not configured it shows
a notice (different copy for admins vs. regular users) with a direct
link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.
Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
diagnostics (message, digest, stack (trimmed), URL, user-agent,
request-id, locale, timestamp) + report-issue link. Clipboard fallback
prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
it replaces the root layout on catastrophic failures — hardcoded
English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
retry, reportIssue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper
Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: small simplifications in i18n/UX changes
- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): capture upstream response body in OpenAI errors
A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.
Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): address code-review + security findings
Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.
Must-fix:
- security: error-details.tsx + global-error.tsx were copying
window.location.href into the clipboard, which on OAuth callback
routes (/api/withings/*, password reset) would leak code/state
tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
transient invalidate failure doesn't abort the chain and no
unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
in try/catch — a dynamic-server error in the root layout now
degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
"24:00" → "00:00" (some ICU builds emit that for midnight under
en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
tokens before logging, and moves the excerpt off Error.message into
a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)
Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
(matches SSR) before falling back to localStorage or browser
detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.
Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
semantics (incl. partial-failure test)
118 → 142 tests. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>