Commit Graph
3417 Commits
Author SHA1 Message Date
Marc-André Bombeck cb9d61a311 perf(dashboard): server-prefetch the snapshot into the first HTML + bundle budget gate
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.

src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.

Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.

H4
2026-07-07 12:51:18 +02:00
Marc-André Bombeck e24db8a889 perf(i18n): stop shipping two full catalogs on every page load
Two independent every-load costs, one per layer:

1. The static EN import in load-locale.ts put the full en.json catalog
   (334 KB raw / ~106 KB gz) into the client JS of EVERY route, for
   every user in every language. No catalog is statically imported by
   client code anymore: the EN fallback floor of the t() chain loads
   lazily on the first actual key miss (the locale-integrity guard
   keeps every locale key-complete, so the miss path is drift-only),
   and the labs biomarker canonical-name lookup fetches EN on demand.

2. The root layout serialized the ENTIRE active-locale catalog into the
   RSC flight payload of every document (392 KB of a 505 KB dashboard
   HTML, re-downloaded on every hard load, uncacheable). The handoff is
   now a versioned, immutable-cacheable boot script
   (/i18n/<locale>?v=<build>, new route handler) that assigns
   self.__HL_I18N before the hydration scripts run, plus a server-side
   cache seed in load-locale.ts behind a typeof-window branch that
   Turbopack compiles out of the browser bundle (verified: zero catalog
   bytes in any client chunk, zero route manifests referencing one).
   SSR text stays correct and the first client render holds the same
   bundle the server rendered with — no EN flash, no hydration drift.

The boot script is public (proxy allowlist: the login page needs it
pre-auth; the body is the same catalog JSON that ships in the public
repo) and cache-first in the service worker, so an offline relaunch
finds it next to the cached shell.

Measured (client-reference manifests, gzip): route JS baseline
/ 391->287 KB gz, /insights 377->273, /measurements 379->275,
/insights/mood 394->290 — the ~104 KB gz EN catalog left every route.

H2
2026-07-07 12:34:26 +02:00
Marc-André Bombeck e260b2d5c4 perf(charts): one shared async boundary for the whole recharts surface
Every next/dynamic chart import used to point at its own module, so
Turbopack emitted a separate chunk group per boundary — the production
build carried EIGHT ~312 KB copies of the recharts module graph, and
/insights (the worst-LCP route) downloaded two of them (~92 KB gz of
pure duplication, one fully unused at load).

All chart loaders now resolve the single chart-runtime barrel
(src/components/charts/chart-runtime.ts), which owns the only path to
recharts: one shared chunk group, recharts exists exactly once in the
build (verified: 8 -> 1 recharts-fingerprinted chunks), cached across
routes. Charts stay recharts and stay pixel-identical; every call site
keeps its own matching skeleton and ssr:false flag.

Riders of the same rule:
- the mood-trio barrel (mood-charts.ts, the v1.16.7 precedent this
  generalises) folds into the runtime and is removed
- the vitals sparkline body extracts to delta-sparkline.tsx and loads
  through the runtime; the tile keeps the fixed 40 px band, so the
  async gap is an identically-sized empty box
- /cycle's BBT chart (the last static recharts import on a live route)
  defers through the runtime with a card-matching skeleton

H3
2026-07-07 12:32:43 +02:00
Marc-André Bombeck 788aac5837 fix(a11y): extend the row-checkbox touch target to 32 px
The 16×16 checkbox on the measurement/mood/values row selectors sits
below the WCAG 2.5.8 24 px minimum. Reuse the switch's ::before
hit-area extension: the visual box stays 16 px, the pointer target
grows to 32×32 without any layout shift.
2026-07-07 12:07:23 +02:00
Marc-André Bombeck 7db97eabca fix(insights): keep mood correlation captions inside their tiles on phone widths
The correlation-card header's right slot kept its icon-era shrink-0
after v1.22 replaced the info glyph with an inline caption — the
unshrinkable badge+caption row escaped the card's right edge by
14–39 px at 390/360 across all five cards and squeezed the title to a
2-line 36–42 % sliver. The caption now stacks under the title below sm
(only the compact strength badge stays pinned right); on >=sm the
inline layout is unchanged.

Same surface: the discovered-relations stat caption ('{n} gepaarte
Tage · r · q') stacks below its finding sentence on phone widths
instead of wrapping to a 4-line sliver at ~25 % of the row, and the
subsection caveat caption stacks under the subheading instead of
wrapping 9 lines beside it.

The phone-width density guard now sweeps /insights/mood too, with
mocked correlation + discovery payloads pinning the populated worst
case (five cards, three mood pairs) so the guard cannot pass vacuously.

H6
2026-07-07 12:06:36 +02:00
Marc-André Bombeck f0b16c1a39 fix(charts): light-theme chart text uses semantic tokens
Recharts tick/label/tooltip colours in the scatter-correlation chart,
the three mood charts, and the admin host-metrics chart referenced the
raw Dracula primitives (--dracula-fg / --dracula-comment), which the
light theme never overrides — near-white text on a near-white card
(~1.0:1) and an AA-failing comment gray (~2.5:1). Swap all 23 sites to
--foreground / --muted-foreground. Dark theme is pixel-identical: the
semantic tokens alias the same Dracula values there (#f8f8f2 /
#9aa3b3); light resolves to the AA-checked soft-neutral values.

H5
2026-07-07 12:03:15 +02:00
Marc-André Bombeck 85058716f6 fix(pwa): stop treating network failure on the auth probe as session end
A failed /api/auth/me now only counts as a session END on an explicit
401/403. Network-level failures (offline relaunch fetch TypeError, the
service worker's synthetic 503 offline envelope, abort/timeout) and 5xx
carry no session verdict: the hook holds the last-known auth state via a
non-sensitive localStorage marker instead of flipping unauthenticated.

Pre-fix, an airplane-mode relaunch of the installed PWA redirected to
/auth/login and ran clearCachesForSessionEnd(), wiping the SW data/page
caches and the IndexedDB query snapshot — the entire offline read path
was unreachable in exactly the scenario it exists for.

- use-auth: isAuthVerdictUnknown() classification, was-authenticated
  marker (set on every successful /me, cleared on session end),
  isAuthUnknown exposed for the shell
- auth-shell: the wipe runs only on a real verdict; a non-verdict
  failure without a prior-session marker redirects without wiping
- explicit logout and true 401/403 expiry wipe exactly as before

H1
2026-07-07 12:02:43 +02:00
Marc-André Bombeck c54386e95b Merge pull request #425 from MBombeck/release/v1.27.15
Release v1.27.15 — complete Google Health coverage
v1.27.15
2026-07-07 11:37:58 +02:00
Marc-André Bombeck 07976121f6 chore(release): v1.27.15 — complete Google Health coverage 2026-07-07 11:27:54 +02:00
Marc-André Bombeck 5cc35aae6e Merge branch 'fix/google-health-coverage' into release/v1.27.15 2026-07-07 11:24:46 +02:00
Marc-André Bombeck ae2e39198a feat(google-health): import blood glucose, core body temperature, and nightly wrist temperature
Full-catalogue coverage pass against the v4 data-types index. Three types
map onto existing MeasurementTypes and are now fetched in the metrics
bundle:

- blood-glucose -> BLOOD_GLUCOSE. `bloodGlucoseMilligramsPerDeciliter` is
  already the canonical mg/dL storage unit; meal/timing/specimen enums are
  not stored.
- core-body-temperature -> BODY_TEMPERATURE. `temperatureCelsius`, the core
  slot — distinct from the surface-temperature buckets.
- daily-sleep-temperature-derivations -> WRIST_TEMPERATURE. The documented
  `nightlyTemperatureCelsius` is an ABSOLUTE reading (the mean of the
  night's skin-temperature samples), which refutes the earlier deviation-
  only premise behind the launch omission. The baseline/stddev derivations
  are not stored — the user's own series carries the baseline.

Every other catalogue type is now an explicit documented skip in
mapping.md with its reason (per-sample twins of the chosen daily grains,
zone-minutes types without a HealthLog concept, total-calories folding in
BMR, hydration/nutrition without a module, ECG/IRN behind their scope
policy). The structure probe iterates the type table, so the three new
types are probed automatically.
2026-07-07 11:17:06 +02:00
Marc-André Bombeck 849607e7df fix(google-health): reconcile the daily-summary filter and two leaf fields with the v4 reference
Daily HRV and resting heart rate returned HTTP 200 with zero imports on a
live Fitbit-band account. Full reconciliation against the v4 reference
(data-types index, DataPoint schema, list/reconcile/dailyRollUp pages)
surfaces three defects:

- The daily-summary `.date` filter prefix is the docs' own contradiction:
  the data-types index's filter-parameter column says snake_case
  (`daily_heart_rate_variability`), while the list reference's only worked
  daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the
  camelCase payload key. Live, the snake form is accepted (200) but matches
  nothing. Send the worked-example camelCase form first and re-walk once
  with snake_case if the very first page 400s; annotate the accepted style
  (`googleHealth.dateFilter.style`) and surface it through the structure
  probe as `requestShape` so a live account settles the grammar.
- `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the
  previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so
  RESPIRATORY_RATE never parsed a single row.
- `height` carries `heightMillimeters` (int64 string), not `heightMeters`;
  the heightCm profile seed never fired. Convert mm -> cm.

Also cover the documented Exercise.ExerciseType catalogue in the sport map
(MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL,
FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values
previously fell through to "other".

Tests pin the documented field names per fixture, the camel-first/snake-
fallback walk (including no-fallback for sample shapes and unfiltered
backfills), and the extended sport map. mapping.md records the filter
contradiction and the corrected leaves.
2026-07-07 11:16:27 +02:00
Marc-André Bombeck 4b466be1d2 Merge pull request #424 from MBombeck/release/v1.27.14
Release v1.27.14 — cross-device dose sync and phone-width polish
v1.27.14
2026-07-07 10:52:02 +02:00
Marc-André Bombeck 456a547ada chore(release): v1.27.14 — cross-device dose sync and phone-width polish 2026-07-07 10:41:52 +02:00
Marc-André Bombeck d7b98ad9f8 Merge branch 'fix/mobile-density' into release/v1.27.14 2026-07-07 10:38:17 +02:00
Marc-André Bombeck 714aa0f2c0 test(e2e): guard phone widths against overflow and tile escapes
New density guard at 390x844 and 360x780 on the dashboard and
/insights: page scrollWidth never exceeds the viewport, and no
descendant's visible box crosses its card / hero / list-row edge
(clip-aware below the tile; content cut off at the tile's own edge
counts as an escape). The snapshot mock gains hero fixtures — layout
heroVisible, three score rings, and a briefing whose signal rows pair
long wrapping headlines with wide delta strings — so the guard
exercises the exact shape that regressed, and asserts the spotlight
rows actually render rather than passing vacuously.
2026-07-07 10:36:57 +02:00
Marc-André Bombeck 29e2b44310 fix(insights): stack the health-score delta caption below the delta line
The v1.22 inline explainer caption rendered INSIDE the delta line's
inline-flex row, so the short delta text ("-2 vs last week") wrapped
into a narrow four-line column beside the multi-sentence read on every
viewport. The wrapper now stacks: delta line with its trend icon on
top, the caption below at full width. The aria-describedby thread from
the delta digit to the caption id is unchanged.
2026-07-07 10:36:45 +02:00
Marc-André Bombeck 39034afcdc fix(dashboard): stack briefing-row headline over delta on phone widths
A long signal headline beside a wide delta string squeezed the headline
into a one-word-per-line column on the hero spotlight rows; at 360 px
the delta pushed past the tile edge and got cut off by the band's
overflow clip. Below sm the headline/delta pair now stacks — headline
wraps at full row width, the delta sits beneath as a left-aligned
tone-coloured line — and the side-by-side row returns at sm+. Same
treatment on the /insights briefing card's shared finding/signal rows,
which speak the identical row language.
2026-07-07 10:36:33 +02:00
Marc-André Bombeck 28749ec6e7 docs(dashboard): describe the MED_COMPLIANCE ring as today's dose tally
The scoreRings doc comment still called the ring the pooled 7-day
adherence — that was the v1.27.7 first cut. Since v1.27.8 the ring is
today's taken/scheduled dose progress from the snapshot's own medsToday
block (see src/lib/dashboard/score-rings.ts). Comment-only change.
2026-07-07 10:22:17 +02:00
Marc-André Bombeck 043698a4d3 docs(env): document IP_GEO_ALLOW_INSECURE in .env.example
The opt-in shipped in v1.25.6 and is documented in
.env.production.example, but the dev-facing .env.example never mentioned
it. Add the commented entry with the plaintext-egress caveat. The
check-env gate reads scripts/env-manifest.json, not the example files,
so this is documentation only.
2026-07-07 10:22:17 +02:00
Marc-André Bombeck dca4ca544e feat(medications): dispatch the intake sync push from every intake mutation route
The cross-device sync push fired only from POST /api/medications/intake
and /intake/bulk. The five other routes that mutate intake state — the
per-medication dose log, the intake-event edit and delete, the bulk
delete, the history import and the purge — persisted the change but
never woke the user's other devices, so a dose undone or edited on the
web left a stale Live Activity / widget until the next foreground or
background wake.

Queue the same coalesced fan-out from all five after the canonical
write, excluding the originating device via X-Device-Id as on the
existing paths. Best-effort as everywhere else: a push miss never
affects the mutation response. The import skips the queue when zero
rows imported, and the bulk delete returns before the hook on a no-op
match.
2026-07-07 10:22:06 +02:00
Marc-André Bombeck b9b38d182c feat(notifications): coalesce the intake sync push per user and strip its payload
The MEDICATION_INTAKE_SYNC fan-out used to fire one silent APNs per
distinct dose slot, so a bulk intake marking five doses woke every other
device five times. Replace the per-slot dispatch with a per-user
coalescer: the first mutation in a burst dispatches immediately, every
further mutation inside the 2 s window folds into one trailing fan-out.
A bulk request now costs exactly one queue call, and a rapid burst of N
single-dose mutations costs at most two fan-outs. The window state is
in-process; multiple app instances throttle independently, which is
accepted because the iOS reconcile is idempotent and the deployment
model is a single container.

The silent payload shrinks to a pure sync trigger — eventType +
syncedAt. The per-slot medicationId / scheduledFor fields are gone: the
iOS handler runs a full reconcile regardless, a coalesced burst has no
single slot to name, and a push payload travels through Apple's
infrastructure, so it now carries no health data by construction. The
Live Activity end push likewise sends only the ActivityKit envelope.

The trailing flush keeps the origin skip only when every coalesced
mutation came from the same device; a mixed-origin burst wakes everyone.
Each flush still records one push_attempts ledger row, reaps
hard-rejected device tokens, and annotates the wide event with the
recipient count and coalesced-mutation count.
2026-07-07 10:21:53 +02:00
Marc-André Bombeck 0da11811e2 docs: bring the README in line with what actually ships
The records bullet claimed inbound document handling — the document
vault is disabled and belongs on the roadmap, not in the feature list.
The device bullet gains Google Health and Nightscout, the check-in
bullet gains WHO-5 and the SCI, the AI bullet reflects guideline-band
interpretation and acceptable plans, and the language claim grows from
two locales to the six that ship.
2026-07-07 09:26:09 +02:00
Marc-André Bombeck d40c5898f7 Merge pull request #422 from MBombeck/release/v1.27.13
Release v1.27.13 — assessments that interpret
v1.27.13
2026-07-07 00:55:37 +02:00
Marc-André Bombeck 9ea22b8a98 chore: drop an unused registry-keys export 2026-07-07 00:45:23 +02:00
Marc-André Bombeck c7b4ccf24a chore(release): v1.27.13 — assessments that interpret 2026-07-07 00:41:30 +02:00
Marc-André Bombeck 52d09d17c7 feat(insights): inject guideline band placement into generic metric assessments
Thread the interpretation block into the generic per-metric assessment
path (the ~40 HealthKit metric cards). For a metric the knowledge base
covers, the prompt now carries the band table plus the server-computed
position of the current value, so the assessment names where the value
sits on the guideline scale and judges the trend by that position — the
visceral-fat 2.7 case now reads as "comfortably in the healthy band"
rather than reciting how many times it was logged.

The block is absent for uncovered metrics and for a sex-split band with
no known sex, so those cards fall back to exactly today's personal-relative
behaviour. Band edges carry no strip risk here — the generic assessment
path runs no post-generation numeric grounding gate.
2026-07-07 00:36:43 +02:00
Marc-André Bombeck 3ce396081f feat(insights): anti-recitation and interpretation-depth prompt contracts
Add two shared cross-surface contracts and compose them into the
assessment cards (base-system) and the comprehensive overview + daily
briefing (insight-generator):

- anti-recitation: a measurement count, a logging cadence, or page
  mechanics is not an insight and may only appear when it carries a
  consequence — stated as the consequence, not the count.
- interpretation-depth: say what a value MEANS, place it on its guideline
  scale where one is attached, judge the trend by band position, and frame
  the consequence without diagnosis; where no band exists, interpret
  against the person's own baseline and say so honestly.

The cross-surface coverage guard now asserts both contracts reach the
surfaces that enforce them. The briefing's grounding and format machinery
is untouched.
2026-07-07 00:36:34 +02:00
Marc-André Bombeck 8fc71620e8 feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline
interpretation bands derived strictly from the health knowledge base —
band edges, plain labels, valence, direction-of-good, and a source tag,
each entry citing the KB file + section it came from. A metric the KB
gives no numeric band for gets no entry, so its assessment stays
personal-relative rather than inventing a threshold.

The band position is computed server-side (`classifyBandPosition`, strict
upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock`
renders the table + placement into the assessment prompt so the model states
where a value sits and what the band means instead of reciting counts.
`interpretationBandEdges` exposes the edges for any numeric grounding gate.

Covers visceral fat, resting heart rate, blood oxygen, respiratory rate,
body temperature, sleep duration, waist (sex-split), waist-to-height,
pulse-wave velocity, and BMI. Tests pin every band edge against the
guideline value so a silent edit breaks a test.
2026-07-07 00:36:25 +02:00
Marc-André Bombeck ca1e8c2415 Merge pull request #421 from MBombeck/release/v1.27.12
Release v1.27.12 — Google Health daily totals
v1.27.12
2026-07-06 20:38:42 +02:00
Marc-André Bombeck 8e2fff4e7f chore(release): v1.27.12 — Google Health daily totals 2026-07-06 20:29:17 +02:00
Marc-André Bombeck 3653402d4a feat(google-health): report the accepted rollup shape from the structure probe
The structure probe (POST /api/integrations/google-health/test with
probe:"structure") already exercises one 7-day dailyRollUp per cumulative
type; it now also reports which documented request shape Google accepted
(days90 standard chunking vs days14 conservative fallback) as
requestShape, next to the field-name skeleton. A self-hoster can settle
the range-constraint question from the UI without server logs; no value,
timestamp, or identifier is added to the output.
2026-07-06 20:24:50 +02:00
Marc-André Bombeck de86ee26f2 fix(google-health): one broken collection no longer kills its siblings
The live 400 on fetchSteps suppressed fetchDistance / fetchFloors /
fetchActiveEnergy entirely: handleCollectionFetchError rethrew anything
that was not a per-class 403, and the rethrow escaped the rollup loop in
sync-activity, emptying the whole cumulative group after the first bad
collection.

- handleCollectionFetchError records the classified failure, warns with
  the per-collection detail, and returns instead of rethrowing; every
  call site already sits in a per-collection catch, so the sibling
  collections keep fetching.
- A per-cycle hard-fail ledger (AsyncLocalStorage, mirroring the 403
  soft-skip tracker) still fails the cycle: the run counts as a partial
  error, the watermark is not stamped, and the next tick refetches the
  window - a broken collection cannot silently advance past its gap.
- The orchestrator's per-resource warning names the resource explicitly;
  fn.name minifies to a single letter in the production bundle (the live
  log read "google-health c failed").
- Tests pin the no-rethrow contract per classification and the activity
  loop reaching all four rollup types plus VO2 max after a steps failure.
2026-07-06 20:24:40 +02:00
Marc-André Bombeck 1c3c733fab fix(google-health): build dailyRollUp ranges the way the docs demonstrate
The first live sync on v1.27.4 imported every list-based type but died on
the first cumulative chunk: fetchSteps(chunk=0,page=0) returned
HTTP 400 INVALID_ARGUMENT "Invalid argument in request.".

Re-verified the request contract against the v4 reference:

- Request body (users.dataTypes.dataPoints/dailyRollUp): range
  { start/end: CivilDateTime }, windowSizeDays, pageSize, pageToken,
  dataSourceFamily - every field the client sends is legal under that
  exact name.
- CivilDateTime (inlined on the DataPoint page): nested
  {date:{year,month,day}, time:{hours,minutes,seconds,nanos}} objects.
- range: "Required. Closed-open range of data points that will be rolled
  up. The start time must be aligned with the aggregation window. The
  maximum range for calories-in-heart-rate-zone, heart-rate,
  active-minutes and total-calories is 14 days. The maximum range for
  all other data types is 90 days." - steps / distance / active-energy /
  floors sit in the 90-day class, so the 90-day chunk constant stands
  (the in-file "14-day-max" note applies to heart-rate only).
- The worked dailyRollUp example (developers.google.com/health/endpoints)
  rolls up ONE civil day as start = {date: day, time: 00:00:00} and
  end = {date: SAME day, time: 23:59:59} - the end bound names the last
  covered day, not the next day's midnight.

The previous body sent end = {date: <next day>} with no time object, so
a maximal 90-day chunk touched 91 civil dates - the one divergence from
the documented example, and the prime suspect for the 400 (the docs do
not spell out how the cap counts days).

- buildDailyRollUpBody() pins the documented shape: explicit date+time
  on both bounds, end = last covered day at 23:59:59, windowSizeDays: 1,
  no pageSize (the documented default of 1440 covers the <=90 windows a
  chunk can produce), pageToken threaded on follow-up pages.
- fetchDailyRollUp() keeps 90-day chunking; if the very FIRST request of
  a walk is rejected with a 400 it re-walks the range once at the most
  conservative documented span (14 days, the tightest cap the docs state
  for any type) and annotates googleHealth.rollup.shape with the shape
  that succeeded, so the next live sync reports which constraint binds.
- extractGoogleApiErrorDetail() surfaces AIP-193 error.details[]
  BadRequest fieldViolations as "field: description" fragments (field
  paths and descriptions only, redacted and capped at three), so the
  next 400 names the offending request field in the logs instead of the
  bare "Invalid argument in request.".
- mapping.md documents the verified range semantics; unit tests pin the
  exact body JSON, the 90/91-day boundary, the fallback walk and the
  violation extraction.
2026-07-06 20:24:24 +02:00
Marc-André Bombeck 4ea50c5d64 Merge pull request #420 from MBombeck/release/v1.27.11
Release v1.27.11 — consistency, colour, and locale fixes
v1.27.11
2026-07-05 22:50:54 +02:00
Marc-André Bombeck c5eddef31d test(e2e): pin the shared auth state to an explicit English locale cookie
The server honours User.locale when the locale cookie is absent now, and
the locale-switch spec mirrors its German pick into the shared e2e
user's profile — every spec running after it rendered German and the
English-string assertions failed. The explicit cookie sits at the top
of the resolution ladder, keeping each spec context deterministic;
locale-switch still overrides it inside its own context.
2026-07-05 22:41:43 +02:00
Marc-André Bombeck c49734842b chore(release): v1.27.11 — consistency, colour, and locale fixes 2026-07-05 22:28:39 +02:00
Marc-André Bombeck ec586ba75c fix(dashboard): paint the dose ring in the medication hue, not green
The 'Doses today' ring pinned the ring system's green arc — a colour
that vocabulary reserves for band semantics. Every medication surface
in Insights paints --primary (the compliance progress bars, the
compliance trend line, the drug-level and dose-strength curves), so the
ring now rides a new constant 'meds' hue on that token: flat by design
(both gradient stops on --primary), never a band gradient over pending
morning doses. Contrast, computed: dark #bd93f9 — 6.78:1 card / 5.90:1
bg; light #644ac9 — 6.00:1 card / 5.60:1 bg; both clear the 3:1
graphics floor with room. The guard now windows the assertion to the
dose ring's own SVG and pins primary present, ring-green absent.
2026-07-05 22:11:26 +02:00
Marc-André Bombeck 72ad228f3e fix(ui): lift the remaining explainer prose out of muted
Residual grey-on-content sweep after the page-description lift:
multi-sentence explainer paragraphs are content and read in the
regular foreground, matching the metric sub-page explainer that set
the pattern — the labs biomarker and custom-metric detail leads, the
recovery metric and resilience explainers, and the onboarding
carousel/done-screen body copy. Muted stays reserved for meta:
timestamps, units, captions under a stat, one-sentence description
slots.
2026-07-05 22:01:15 +02:00
Marc-André Bombeck eb730bf7f3 fix(insights): one card language on the sub-page spine
A measured pass over every insights sub-page, the dashboard, and the
settings sections (computed text colour, left text edge relative to the
card edge, header→body distance, per card) surfaced the drift the eye
kept catching:

- The blood-pressure pulse-pressure/MAP explainer was a bare muted
  paragraph at the container edge, 16–24 px left of every neighbouring
  card's text. It now rides a compact Card in the regular foreground.
- The 'Usual range' read strip was a hand-rolled bg-card/60 shell with
  its own padding; rebuilt on Card + TileHeader + CardContent at the
  compact density so background, radius, icon spacing and text edge
  match the neighbouring tiles.
- The spine tiles carried three different header→body distances (6, 12
  and 24 px) via off-scale overrides (gap-1.5, md:gap-3, md:py-5,
  gap-3). Assessment, target summary, medication tiles, the mood
  stand-out card and the sleep hypnogram now share the sanctioned
  compact tier (gap-2 py-3 md:py-4).
- The hero score card and its skeleton kept their translucent identity
  but move from a static px-4 py-4 to the p-4 md:p-6 card norm, putting
  their text on the same edge as every other card.
- Two dashboard shells (recent workouts, recent unlocks) froze at p-4
  on every breakpoint; now p-4 md:p-6.
- Settings: the modules hint moves into the header's description slot
  and the injection-site grid joins the section-wide mt-4 rhythm — the
  two gaps that deviated from the settings 16 px header→content norm.
2026-07-05 22:00:53 +02:00
Marc-André Bombeck a4b204ffe4 refactor(ui): retire the raw dracula utilities for semantic tokens
Migrate every remaining *-dracula-* utility site (127 lint warnings,
193 class occurrences) onto the semantic vocabulary. The swaps are
pixel-identical in both themes — each target token aliases the same
primitive the utility read, including the light-theme AA overrides:

- green/orange/cyan/red → success/warning/info/destructive
- purple → primary (same value in both themes)
- pink → the new --brand-pink token (Coach gradient, Apple-Health
  badge, recording mic): dark #ff79c6 — 6.86:1 card / 5.97:1 bg;
  light #a3134d — 7.32:1 card / 6.83:1 bg (computed)
- yellow sites resolve to their status meaning: warn-level log icon,
  update-available pill and offline-geo hint → warning; the admin
  helpful-rate ramp → success/warning/destructive; the confidence
  meter mirrors the coverage-meter precedent (medium + low share the
  caution tint, the lit count carries the distinction)
- the confidence-badge low tier dropped dead *-dracula-comment/*
  classes (no generated utility) for border-border + bg-muted/50

Close two rule blind spots the sweep surfaced — directional borders
(border-l-dracula-red) and the parenthesised CSS-var shorthand
(bg-(--dracula-green)) — and flip healthlog/no-dracula-utility from
warn to error so the class cannot regrow. var(--dracula-*) references
in chart code stay: they are token references the light theme
overrides, and the chart palette is pinned visually.
2026-07-05 21:28:09 +02:00
Marc-André Bombeck 68b4dd08a7 feat(lint): guard the gap-based Card slots against padding overrides
Add healthlog/spacing-scale (error): pt-*/pb-* on CardHeader/CardContent
fights the gap-based Card contract — the header→body distance comes from
the parent flex gap, so slot padding stacks on top of it or is a no-op.
This is the drift class the earlier slot-padding sweep cleaned; the rule
keeps it from reaccumulating. Density belongs on the Card itself
(gap-2 py-3 md:py-4). No allowlist — an exception, should one ever be
load-bearing, carries an inline disable with its reason.

Remove the two stragglers the rule caught: pb-1 on the device-score tile
and pb-4 md:pb-6 on the intake history list, both stacking dead padding
on top of the card gap. RuleTester coverage for the modifier-prefixed,
arbitrary-value, cn()/ternary/template and member-expression forms.
2026-07-05 21:10:01 +02:00
Marc-André Bombeck e9692f0b81 fix(i18n): survive Safari's 7-day cookie cap on the chosen locale
The first-paint ladder consulted only the healthlog-locale cookie and
Accept-Language — never User.locale. The cookie is written client-side
via document.cookie, which Safari ITP caps at 7 days, so a chosen
language silently reverted to the browser language once a week.

Three seams close it:
- resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts)
  now reads User.locale on a cookie miss via a lightweight session read
  (no sliding-expiry write, no cookie mutation, fail-soft) before the
  Accept-Language fallback.
- PUT /api/auth/me/locale mirrors the accepted locale into the cookie
  via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via
  shouldEmitSecureCookie) — server-set cookies are exempt from the ITP
  cap, and the mount-time backfill hitting the route doubles as the
  weekly refresh.
- getSession re-emits the cookie when the user has a persisted locale
  but the cookie is absent (absent-only, so a mid-switch cookie is
  never reverted before the PUT lands).

Users who never chose a language stay on Accept-Language.
2026-07-05 21:01:50 +02:00
Marc-André Bombeck ef82545870 Merge pull request #419 from MBombeck/release/v1.27.10
Release v1.27.10 — two new check-ins: wellbeing and sleep
v1.27.10
2026-07-05 20:55:19 +02:00
Marc-André Bombeck 0103557566 chore(release): v1.27.10 — two new check-ins: wellbeing and sleep 2026-07-05 20:45:25 +02:00
Marc-André Bombeck ce9b5bec29 fix(mental-health): drop the landing rhythm hint and lift page descriptions to foreground
The cadence sentence between the page header and the instrument cards
reads as clutter once the recall windows live in each instrument's own
description. The canonical PageHeader description also moves from the
muted tier to foreground: it is content, not meta.
2026-07-05 20:45:24 +02:00
Marc-André Bombeck b1688fc8f7 build(deps): adopt the open dependency updates
next 16.2.10, @tanstack/react-query 5.101.2, nodemailer 9.0.3,
react-hook-form 7.81.0, @types/node 26.1.0; docker buildx/metadata/
build-push action pins to their current releases.
2026-07-05 20:37:24 +02:00
Marc-André Bombeck 6e59fe5ce0 Merge branch 'feat/w-g-screenings' into release/v1.27.10 2026-07-05 20:36:50 +02:00
Marc-André Bombeck 8f1485fdcb feat(mental-health): rebuild the check-in page around four instrument cards
The landing slims to intro + instrument cards: the combined history card
leaves the main page entirely. Each card body now opens a per-instrument
detail sheet (the med-/Vorsorge card interaction) carrying the last
score + band, the score-over-time trend chart (restored from the
pre-v1.27.6 chart, lazy Recharts, bands surfacing discreetly in the
tooltip), the dated result history with the crisis re-surface, the Start
action, and the instrument's required attribution line — the Verlauf is
deliberately opt-in behind a click, never pushed onto the page someone
arrives at to take stock.

The wizard drives from the registry: option values in source order (the
WHO-5 leads with 5 = all of the time, the SCI with 4), shared vs
per-item anchor groups, and recall-stem captions above the WHO-5 / SCI
item fragments. WHO-5 items and anchors ship VERBATIM from the official
WHO publication in all six locales; the SCI is validated in English, so
every non-English locale presents the English items plus a localized
'validated in English' note (a self-made translation would invalidate
the instrument — the leak-guard allowlist documents this as intended).
Result view: direction-aware follow-up hints (WHO-5 <= 50 points gently
to the PHQ-9 check-in; SCI <= 16 carries the paper's neutral band
wording; PHQ-9 / GAD-7 keep their >= 10 hint). The intro states each
recall window honestly (two weeks vs past month). The Vorsorge screening
group offers all four instruments. Chrome localized in de/en/es/fr/it/pl.
2026-07-05 20:34:01 +02:00
Marc-André Bombeck 03d4496d3a feat(measurements): surface the WHO5_SCORE and SCI_SCORE trends
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
2026-07-05 20:33:44 +02:00