Commit Graph
2359 Commits
Author SHA1 Message Date
Marc-André Bombeck 267dcad13d feat(modules): unify Vorsorge, Illness and Labs with the medication pattern
- add a wrench beside each module's add button linking to the module's own
  settings page; rename every add button to a plain "hinzufügen"
- give each module a settings page: view (cards/list) + manual reorder; Labs
  also gets a sort-order choice (by last reading) and biomarker CRUD/reorder
- collapse the Illness "abgeschlossen" list by default
- rebuild the Vorsorge card on the medication card's building blocks: a green
  "Jetzt messen" action, the interval as a chip beside the name, and an
  in-interval progress signal; drop the kebab "anpassen" and stray actions
- persist per-module view/order/sort client-side; add reverse-coverage-clean
  strings across all six locales
2026-06-18 21:36:58 +02:00
Marc-André Bombeck ae3091df9e merge(W7): ground the Coach in cited reference ranges
Inject a deterministic, citation-aware grounding block into the coach
snapshot/prompt for the metrics present in a user's snapshot: each
metric's reference band + where the value sits, framed as general
guidance, never a diagnosis, no brand attribution, BP labelled ESH 2023.
Glucose respects the diabetes opt-in resolver. Also fixes the reference
classifier to resolve the normal band by an explicit marker rather than
index 0 (BMI/pulse-pressure were mis-placed) — correcting both the
grounding and the in-app four-state explainer. Steps stay out of the
grounding until step aggregation sums intra-day rows.
2026-06-18 20:42:04 +02:00
Marc-André Bombeck 9265ab89c6 fix(reference): resolve the normal band by marker, not index 0
The reference-range classifier assumed bands[0] was always the
normal/target band, so any metric whose first band is abnormal was
placed wrong: a healthy BMI 22 read as just outside the reference and
an underweight BMI 17 read as inside; pulse pressure had the same flaw
(Narrow is its first band). The same classifier drives the in-app
four-state explainer, so both surfaces were affected.

Mark the population-normal band explicitly with a `normal: true` flag
on every metric, add `normalBandIndex`, and place values by distance
from the marked band rather than from index 0. `headlineBandText` now
renders the marked band, so BMI grounds against 18.5-24.9 kg/m2.

Exclude daily steps from the Coach grounding: it is fed the intra-day
mean of the step rows, which averages a multi-row day down and can
misplace it below the 8,000 floor. Grounding it on a per-day sum is the
correct fix and is left as a follow-up; until then drop the line rather
than ship a wrong placement.

Extend the classifier and grounding tests for BMI 17/22/27, a pulse
pressure case, the normal-band-not-at-index-0 contract, and that steps
is not grounded.
2026-06-18 20:41:29 +02:00
Marc-André Bombeck 22ffb19fa5 feat(coach): ground replies in cited reference ranges
Surface the population reference bands behind the metrics a Coach turn
carries as a compact, citation-aware grounding block appended after the
SNAPSHOT. For each present metric the block states the headline band, its
source (blood pressure on the ESH 2023 line, US/ESC framings as context
only), and where the user's value sits via the shared four-state
placement contract.

Framed strictly as general guidance: the block opens and closes with a
not-a-diagnosis caveat, never overrides the existing refusal/dose/drug-
level safety discipline, and carries no commercial brand name. Glucose
respects the diabetes opt-in — the tighter ADA management goal only when
the user has declared it, otherwise the general non-diabetic band.

The block is deterministic and inspectable: built purely from the
reference-range backbone over the same scalars the snapshot already
surfaces, so it can never cite a value or band the snapshot does not
carry.
2026-06-18 20:32:45 +02:00
Marc-André Bombeck 6fc4b62988 merge(W6): clinical safety floors + diabetes opt-in
Wire absolute red-flag floors (BP >=180/120, SBP <90, glucose <70/<54/
>=250) into the illness notification path, escalating only on a confirmed
re-measured reading inside a 1h window, never diagnosing; asymptomatic ->
re-check/contact doctor, symptom-coupled -> emergency. Glucose gated behind
its module, BP core. Add an opt-in hasDiabetes pref (migration 0174) that
switches the glucose target resolver to the tighter ADA goal bands; never
auto-inferred. Resolver lives in the extracted targets builder.
2026-06-18 20:21:11 +02:00
Marc-André Bombeck 1b39e191a1 merge(W5): cited reference-range backbone + inline explainers
Add src/lib/reference-ranges.ts — a citation-backed per-metric range
backbone with a four-state placement classifier — and expand the medical
citations registry. Fill the empty metric-status normalRange fields,
enrich metric explainers/tooltips with cited bands (SpO2 darker-skin
caveat, device-honesty copy, steps 8,000 floor), label BP as ESH 2023
with ESC/AHA shown only as context, and surface pulse pressure + MAP on
the BP detail from stored readings. General guidance, no brand attribution.
2026-06-18 20:21:03 +02:00
Marc-André Bombeck 961257fffd i18n: add safety-floor alert and diabetes-target strings
Add the safety-floor escalation copy (per-reason title plus a contact-your-
doctor and an emergency body variant) and the diabetes opt-in setting strings
across all six locales. The alert copy frames as general guidance, never names
a condition, and routes symptom-coupled breaches to the emergency wording.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck f20be29189 feat(illness): escalate confirmed clinical safety floors
Add absolute, guideline-backed safety floors that escalate a freshly logged
reading — but only after a second, confirming reading inside a one-hour
re-test window, so a single oscillometric spike or a one-off finger-stick can
never raise a false alarm:

  - blood pressure 180/120 or higher (re-check, contact your doctor; symptom-
    coupled lifts to emergency copy) and a symmetric low-systolic-under-90
    cautionary tier;
  - glucose under 70 (low) and under 54 (very low), and a sustained very-high
    250-or-higher tier. The 200 DKA criterion deliberately never shows an
    all-clear and never urgently escalates on its own, since a value alone
    cannot rule euglycemic DKA in or out.

The escalation reuses the urgent SYSTEM_ALERT path with a 24h per-reason
dedup, never names a condition, and gates glucose behind the glucose module.
An optional transient `symptomsPresent` flag on the measurement write lifts a
confirmed breach from the contact-your-doctor copy to the emergency copy. The
measurement POST runs the check fire-and-forget so a notification failure
never fails the user's write.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck cf620de315 feat(glucose): resolve tighter ADA goal targets when diabetes is on
Add a diabetes-aware glucose target resolver: when the user has opted in, the
fasting/post-prandial targets switch to the tighter ADA glycemic GOAL bands
(fasting 80–130, post-meal under 180 mg/dL) instead of the general
non-diabetic normal bands. A user threshold override always wins over both, so
a clinician-pinned individualised goal is never replaced by the population
band. The targets surface now reads through the resolver and labels the source
accordingly. The general band display is untouched.
2026-06-18 20:18:37 +02:00
Marc-André Bombeck 2c87a93827 feat(glucose): add a diabetes opt-in preference
Introduce an explicit, user-declared "I have diabetes / clinician glucose
targets" preference (`User.hasDiabetes`, migration 0174, defaulted false) plus
its GET/PATCH endpoint at `/api/auth/me/diabetes`. The flag selects which
glucose target band a reading is judged against; it is never inferred from a
value and never asserts a diagnosis. Mirrors the existing per-user-scalar
preference pattern: 60/min rate limit, Zod validation, audit-logged,
field-by-field write. OpenAPI regenerated for the new route.
2026-06-18 20:17:13 +02:00
Marc-André Bombeck 9be30897fa feat(insights): add a cited reference-range backbone and richer metric explainers
Introduce src/lib/reference-ranges.ts: a structured, citation-backed
per-metric reference-range backbone (band + source body + year) with a
deterministic four-state placement helper, read by the UI surfaces. Each
band traces to the medical-citations registry, which gains the supporting
sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse
pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity,
FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature,
RCP NEWS2 and American Lung Association respiratory rate, visceral-fat
imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV,
Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep).

Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and
ESC 2024 appear only as displayed placement context. Enrich the metric
explainers with concise cited reference bands and a general-guidance
tooltip on every metric page: a darker-skin pulse-oximeter over-read
caveat, device-proxy honesty for readiness/recovery/strain/stress, an
HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill
the empty registry normal ranges, reconcile the steps green floor to
8,000, and tighten the body-temperature band below the fever line. Add
free pulse-pressure and mean-arterial-pressure context on the blood-
pressure detail page, plus cycle-physiology and sleep-hygiene copy across
all six locales. Cycle thresholds carry a verification marker pending an
ACOG source check.
2026-06-18 20:17:04 +02:00
Marc-André Bombeck 1d83e10764 merge(W4): module-by-module guided tour
Extend the in-house spotlight into a cross-page tour: 15 module stops,
each gated on its module, walking the app and teaching what each module
does and how it feeds the others. Auto-starts after the anamnesis,
resumes from onboarding_tour_progress_json, re-enters per-module via a
header trigger, and replays from one Settings card. Adds the tour
progress pref + migration 0173 and the onboarding tour checkpoint route.

# Conflicts:
#	src/components/onboarding/DoneScreen.tsx
2026-06-18 19:55:00 +02:00
Marc-André Bombeck 91fd0b438e refactor(onboarding): stabilise the tour progress callback
Memoise the launcher's progress handler so the overlay checkpoint effect
fires on a real step transition rather than on every launcher re-render.
2026-06-18 19:52:40 +02:00
Marc-André Bombeck 33fec28b29 feat(onboarding): per-module tour re-entry + consolidated replay
Add a 'Diese Tour zeigen' affordance to every module header that opens the
tour narrowed to that module's card in place, plus additive data-tour
anchors (measurements/medications/labs/illness/vorsorge/cycle/mood/
insights/coach/integrations/export/achievements) so the cross-page walk
has a real target on each page. Consolidate the full-tour replay onto a
single Settings -> Advanced card, relabel it, and drop the duplicate from
About. The replay reset clears the stored progress so it restarts cleanly.
2026-06-18 19:49:37 +02:00
Marc-André Bombeck f0c25cc275 feat(onboarding): rebuild the tour as a 15-stop cross-page module walk
Replace the five chrome-pointer stops with a module-by-module guided tour
in the data->intelligence->export order. Each stop teaches the module's
value, one hidden capability, and how it feeds the others. Stops carry a
route + module gate; the overlay router.pushes to the route, waits for the
anchor to mount, then measures, so a single tour walks the whole product.

Move the launcher to the app shell so the overlay survives navigation,
auto-open immediately after anamnesis for fresh users (24h path stays the
fallback for wizard-skippers), resume from the persisted checkpoint, and
gate stops + the step counter on the resolved module map. Add tour copy
for all 15 stops across the six locales.
2026-06-18 19:39:10 +02:00
Marc-André Bombeck 59e7b33285 feat(onboarding): add resumable module-tour progress field + API
Add nullable onboarding_tour_progress_json to users (migration 0173),
a Zod-validated progress shape, and extend POST /api/onboarding/tour to
accept a mid-tour resume checkpoint and clear it on replay. Surface the
resume point on GET /api/auth/me and register the contract in OpenAPI so
the native client mirrors it.
2026-06-18 19:27:28 +02:00
Marc-André Bombeck 56b6d5b998 merge(W3): product clarity and coherence
End the AI dead-end — onboarding gains a skippable provider card and the
Coach/Insights/Briefing surfaces explain what the AI does and why a
provider is needed instead of a bare error. Extract buildDashboardBands
so band/target math is one server-side source the page fallback reuses.
Surface Rest Mode on the score card, restore the module-hub intro, link
the glucose panel to Labs, and route the remaining day-key copies through
the canonical helper.
2026-06-18 19:19:58 +02:00
Marc-André Bombeck e0d4d809dd feat(glucose): cross-link the clinical panel to Labs
Glucose has three front doors — measurements/CGM as readings over time,
the clinical panel as derived TIR/GMI/estimated-A1c, and Labs as dated
panel results — with no signpost between them. The clinical panel now
carries a one-line cross-link clarifying that a blood-draw HbA1c or
fasting glucose belongs in Labs, so the spot-estimate view and the
lab-result view reference each other instead of competing. Adds
insights.bloodGlucose.clinical.labsLink across all six locales.
2026-06-18 19:17:36 +02:00
Marc-André Bombeck 04adcc6aa5 feat(settings,measurements): restore module-hub intro and clarify list/insights split
The module hub lost its intro line in v1.18.1, so a new user could not
tell that the page drives the navigation, tiles and assessments they
see. Restore the section intro and the toggleable-modules hint (both
strings already in the bundle). Reframe the measurements subtitle from a
metric list to its role — log + full history here, trends and analysis
under Insights — so the two homes for 'look at my data' read as distinct
rather than redundant.
2026-06-18 19:15:51 +02:00
Marc-André Bombeck ef1fbb8396 feat(insights): state Rest Mode on the health-score card
During an active illness episode the server suppresses (never penalises)
the health score, but the card gave no sign of it, so a held number read
as a silent decline. The card now carries an explicit 'paused during
illness — not being judged today' line when Rest Mode is active, driven
from the value-free annotation already on the analytics payload. Adds
insights.healthScore.restModePaused across all six locales.
2026-06-18 19:11:58 +02:00
Marc-André Bombeck cd816607e4 feat(onboarding): surface AI-provider setup on the done step
AI Insights, Coach and the daily briefing are default-ON but need a BYOK
or local provider that onboarding never mentioned, so a fresh user met
the flagship feature as a cold error. Add an optional, skippable card to
the onboarding done step that explains what the AI does (plain-language
summaries of your readings, data stays with you) and deep-links to
Settings -> AI. Adds onboarding.done.aiTitle/aiBody/aiCta across all six
locales.
2026-06-18 19:09:12 +02:00
Marc-André Bombeck 3d213d1fbf feat(insights): turn the no-provider dead-ends into guided setup
Coach, the daily briefing, and the per-metric assessment tiles all
default ON but need a connected AI provider that onboarding never
mentions, so a fresh user hits a bare error on the headline feature.

- The daily-briefing and per-metric no-provider states now explain what
  the AI does and why a provider is needed (your own key or a local
  model, data stays with you) rather than a one-line 'unavailable'.
- The per-metric tile gains a direct Settings link.
- The Coach 'no provider configured' turn maps to its own copy with a
  Settings link instead of the transient 'try again in a moment' that
  would 422 forever.

Adds insights.noProviderAction, insights.coach.errorNoProvider and
errorNoProviderAction across all six locales.
2026-06-18 19:07:38 +02:00
Marc-André Bombeck 63bca26177 refactor(dashboard): extract buildDashboardBands into a client-safe leaf
The band / target math lived inline in two places — the server snapshot
builder and the dashboard page's snapshot-disabled fallback — so a
threshold change had to be hand-mirrored or the two surfaces would grade
the same metric differently. Move the construction into the new
client-safe @/lib/dashboard/bands leaf (buildDashboardBands, with
buildTargetBands kept as an alias for the snapshot-era call sites) and
have both the snapshot builder and the page fallback call it. The page
always computes the client copy from the profile so a still-loading
snapshot frame keeps real bands, then prefers the server numbers once
they arrive. Behaviour-preserving.
2026-06-18 19:02:05 +02:00
Marc-André Bombeck f22e39872e refactor(tz): route coach/verdict/mood day-keys through userDayKey
The coach snapshot tzDayKey, the dashboard verdict dayKeyInTz, and the
mood-entry moodDateKey each carried their own Intl day-key formatter
(en-CA / en-CA / sv-SE). Delegate all three to the canonical userDayKey
so every day-bucket key stays byte-aligned and the locale decision
lives in one place. Behaviour-preserving.
2026-06-18 18:53:07 +02:00
Marc-André Bombeck e15e8ef428 merge(W2): i18n dead-key sweep, reverse-coverage guard, primitives, docs
Remove 177 verified-dead i18n keys across all six locales and add a
reverse-coverage test so orphans cannot re-accumulate. Drop forwardRef
from textarea/native-select (React 19 ref-as-prop), route a consumption
write through toJson(), cap the offline persister snapshot at 2 MB and
surface quota errors instead of swallowing them. Refresh CLAUDE.md drift
(model count, React pin, release line, directory paths, symbol anchors).
2026-06-18 18:48:14 +02:00
Marc-André Bombeck 36090daa70 docs: refresh the contributor-manual drift in one pass
Correct the accreted drift in CLAUDE.md against the current tree: 61→80 Prisma
models (tech table + schema row), the React 19.2.x exact pin (drop the stale
patch digit), and the v1.12.x line marker → v1.18.x. Re-point the two paths
that are now directories — `src/lib/openapi/routes/` (23 modules) and
`src/lib/query-keys/` (16 modules), both with `index.ts` barrels. Replace the
exact line anchors that have since moved (requireAdmin, the rollup
whitelist-splice, isPublicUrl, the Dockerfile pg-boss/prisma-cli install,
api-handler LOC) with symbol references that survive line drift, fix the AI
providers row to cite `provider.ts`/`resolveProvider` plus the
`PROVIDER_CHAIN_TYPES` allowlist, and correct the `apiSuccess(data, status?)`
signature in the request-flow diagram.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck 3465cbe81c fix(pwa): bound the persisted query cache and stop swallowing quota errors
Cap the serialized snapshot at 2 MB and skip the write when it's over — an
oversized blob is the likeliest IndexedDB quota trigger and its offline value
is marginal, so keeping the last good snapshot beats evicting it. When a write
still hits QuotaExceededError, log it and drop the persisted blob so the next
flush has room, instead of silently swallowing the failure and leaving the
offline cache stuck on a stale snapshot the user can't explain.

The allowlist and per-account binding are untouched.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck 151a56fc66 refactor(ui): adopt the React 19 ref-as-prop idiom and the toJson helper
Drop forwardRef from the textarea and native-select primitives — the last two
holdouts under components/ui — so they accept ref as a plain prop like the
input primitive already does. No call site passes a ref to either, and the
React 19 prop path is the forward-looking shape.

Route the inventory-consumption JSON-column write through the shared toJson
helper instead of an inline `as unknown as Prisma.InputJsonValue` cast, keeping
the WHY of the escape hatch in one place.
2026-06-18 18:47:19 +02:00
Marc-André Bombeck d50e277a2b chore(i18n): remove orphaned translation keys and add a reverse-coverage guard
Delete 177 translation keys that no call site reaches — across all six locales
— covering the medications, cycle, labs, notifications, nav, targets, and
admin namespaces that drifted out of use as their surfaces were rebuilt. The
forward call-site guard only catches keys referenced from code that never
shipped to the bundle; nothing watched the reverse, so orphans piled up.

Add a reverse-coverage test that walks messages/en.json and asserts every leaf
is reachable: a literal t() key, a subtree returned by t("ns"), a dynamic key
built inside a t(`…`) template or by string concatenation (the per-metric
${i18nPrefix}.* pages, cycle.calendar.flow${LEVEL}, medications.site${Suffix},
"notifications.event" + type), or an explicit allowlist for DB-driven keys
(mood.tag / mood.tagCategory). A new orphan now prints as a punch list.

Drop the unrendered admin.carrierUnknown and dashboard.bpInTargetShort from the
drift / trend-card guards — both lost their call sites and were removed here.
2026-06-18 18:47:05 +02:00
Marc-André Bombeck b4a3355fd0 merge(W1): backend perf, resilience, timezone correctness
startOfLocalDayInTz DST-safe day anchor (fixes the intake UTC-midnight
off-by-offset bug) + userDayKey/wallClockInTz dedup across scheduling,
consolidation and correlations; illness-insights N+1 collapsed under a
concurrency cap; series-batch per-type isolation with degraded_types
meta; rest-mode episode read memoised per request; targets response
builder extracted to src/lib/targets.
2026-06-18 18:35:45 +02:00
Marc-André Bombeck 0af1fbb9d4 refactor(targets): extract the insights-targets builder into lib
The targets route held a ~1.4k-line domain function in the handler body.
Move buildTargetsResponse into src/lib/targets/build-response so it sits
beside the other targets helpers and can be unit-tested without the route
shell; the route is now auth -> cachedSwr(buildTargetsResponse) -> apiSuccess.

Follow the as-needed compliance source pin to the new file location.
2026-06-18 18:34:27 +02:00
Marc-André Bombeck 075dae9ace perf(illness): dedupe the active-episode read per request
resolveRestMode is resolved from several surfaces in one request — the
snapshot builder, the recovery score, the analytics route, and coincident-
deviation — each firing an identical active-episode findMany for the same
account. Memoise the read per request keyed on (userId, asOf instant); an
explicit client bypasses the memo so transaction and test reads are never
served a default-client cached result.
2026-06-18 18:31:25 +02:00
Marc-André Bombeck 04b0f38537 fix(measurements): isolate per-type failures in the series batch
The batched daily-series endpoint ran every type through one Promise.all
with no per-task catch, so a single type's transient reject 500'd the whole
batch and blanked every preloaded chart in the dashboard row at once.

Wrap each per-type read so a failure degrades to an empty slice for that
type only; the failing chart self-fetches through its existing fallback.
A degraded_types counter rides the wide event.
2026-06-18 18:29:46 +02:00
Marc-André Bombeck 1a73afa7e9 perf(illness): parallelise the insights recovery-gap fan-out
The cross-episode insights route looped up to 24 episodes, each running a
sequential 10-vital scan with two serial reads per vital — ~500 serialised
round-trips on a year of episodes, starving the shared Prisma pool.

Pair the baseline + episode-window reads, fan the per-vital scan out under a
p-limit(4) cap, and run the per-episode correlations concurrently under the
same discipline. Series order and the gated outputs are unchanged.
2026-06-18 18:28:37 +02:00
Marc-André Bombeck cbfc4a36d2 refactor(tz): route day-key copies through the canonical userDayKey
Compliance streaks, the measurement-consolidation day key, and the
correlations weekday helper each carried their own Intl formatter
(en-CA / sv-SE / short-weekday). Delegate them to userDayKey and
wallClockInTz so every day-bucket key stays byte-for-byte aligned and the
locale decision lives in one place.
2026-06-18 18:27:06 +02:00
Marc-André Bombeck 1b354de68b fix(tz): anchor medication-intake day floor at local midnight
The intake aggregator built its day floor from en-US calendar parts pinned
to UTC midnight, so today's window opened off by the zone offset for any
non-UTC user — a 23:30-local reading could land in the wrong day's bucket.

Promote the DST-safe start-of-local-day primitive into src/lib/tz/local-day
as startOfLocalDayInTz and route the intake route, the cadence start-of-day,
and the recurrence day floor through it, removing three divergent copies.
2026-06-18 18:25:03 +02:00
Marc-André Bombeck b474ea1489 fix(pwa): stop the offline cache from persisting sensitive health data
The offline layer wrote far too much to disk and cleared it too rarely.
The query persister used a denylist (only auth/session/admin/tokens), so
every other family — coach, insights, illness, labs, mood, cycle — was
dehydrated to IndexedDB in plaintext; the service worker likewise
prefix-cached GET /api/insights, which serves decrypted Coach
conversations. Neither store carried an account marker and both were
wiped only on explicit logout, so a session that ended by expiry or a
401 left health data on disk for the next account on a shared profile.

Flip the persister to a strict allowlist (dashboard snapshot, per-chart
series, widget layout — the offline-dashboard set only) and stamp each
payload with the live account id, discarding on mismatch. Drop
/api/insights and /api/analytics from the worker allowlist. Wipe the
IndexedDB blob and the data/page caches on every session end — the
401/expiry redirect and the logout path both route through one helper.
2026-06-18 18:18:24 +02:00
Marc-André Bombeck 3cf9a262ca test(e2e): keep route mocks authoritative against the v1.18.6 service worker
The production build registers a service worker whose v1.18.6 data branch
serves allowlisted GET /api/* reads network-first. A worker-originated
fetch is not subject to Playwright's page.route interception, so the
worker re-fetched the real (empty) backend and bypassed every per-spec
route.fulfill — a just-created row never surfaced and read-after-write
assertions failed. Block the worker in the test context so the route
mocks are authoritative; the shipped offline cache is untouched for real
users. Separately, the dashboard now batches chart series into
/api/measurements/series-batch, a sub-path the **/api/measurements* glob
does not match, so it fell through to the empty backend and each chart
painted an empty preloaded slice; stub the endpoint with the same
populated set.
2026-06-18 17:48:57 +02:00
Marc-André Bombeck 9633d66eea fix(pwa): restore persisted query cache as gap-fill, never overwrite live data
restorePersistedQueryCache hydrated the dehydrated snapshot
unconditionally, so a restore that resolves in an effect AFTER the page
has already fetched a fresh result overwrote that live query with the
stale (often empty) on-disk copy — a read opened straight after a
mutation then showed pre-mutation data. Filter the dehydrated state to
keys the live cache does not already hold before hydrate, so restore only
fills gaps; a query already present is by definition fresher than disk.
Scope the post-restore invalidation to the keys actually hydrated so a
live query is never disturbed. Adds an invariant test covering the
clobber case.
2026-06-18 17:48:57 +02:00
Marc-André Bombeck 33dcf39adf fix(a11y): hold revealed state under the reduced-motion backstop
The global prefers-reduced-motion catch-all collapsed every animation to
near-zero duration but left animation-fill-mode at its default, so an
entrance animation whose element rests at opacity:0 and only reveals
through its keyframe snapped back to the hidden base style once the
near-instant animation completed. Under reduced motion that left charts
and list rows permanently invisible. Pin animation-fill-mode:forwards and
zero the stagger delay so the shortened reveal holds its final visible
frame and paints immediately. Per-component blocks that set animation:none
are unaffected (no animation to fill).
2026-06-18 17:48:41 +02:00
Marc-André Bombeck 15a628db47 fix(pwa): SW /api reads network-first (cache only as offline fallback) — restores read-after-write freshness 2026-06-18 17:10:47 +02:00
Marc-André Bombeck 730430728d fix(pwa): serve allowlisted API reads network-first
The offline service worker cached allowlisted GET /api/* reads with
stale-while-revalidate, serving the cached pre-mutation copy first and
revalidating in the background. Online, a list fetched straight after a
create or update rendered the old (or empty) data until the next paint,
breaking read-after-write.

Switch the data branch to network-first: always fetch fresh from the
network online and refresh the cache behind it; fall back to the last
cached copy only when the network fails (offline). The offline fallback,
the allowlist plus auth/token/secret-body boundary, and the
release-versioned cache name are unchanged.

Add a regression guard to the cache-policy test that reads public/sw.js
and asserts the network-first wiring and the absence of the old
stale-while-revalidate handler.
2026-06-18 17:09:56 +02:00
Marc-André Bombeck 36ce82866a chore(release): v1.18.6 2026-06-18 15:35:49 +02:00
Marc-André Bombeck 82c6947633 perf(dashboard): batched rollup series endpoint + server-side target bands + gate-helper split 2026-06-18 15:33:53 +02:00
Marc-André Bombeck 437e501536 refactor(dashboard): thin page orchestrator + consume server bands and batched series
Extract the pure first-paint / skeleton-reservation gates
(resolveDashboardFirstPaintGate, resolveConfiguredTileCount,
resolveChartRowPlaceholderCount + their widget-id sets) into
components/dashboard/dashboard-gates; page.tsx re-exports them so the
existing unit-test imports stay valid. Wire the page to read the
server-computed targetBands when snapshot mode is on (legacy client
compute kept for the snapshot-off fallback) and to issue one
series-batch fetch for the visible non-sleep charts, threading each
chart its slice via preloadedSeries.
2026-06-18 15:29:29 +02:00
Marc-André Bombeck 143aefc709 feat(api): batched rollup-backed series endpoint to coalesce dashboard fetches
Add GET /api/measurements/series-batch returning every requested
MeasurementType's daily series in one response, so the dashboard chart
row stops issuing one self-fetch per chart. Extract the rollup-daily read
(per-source collapse, coverage probe + inline fold, live-SQL fallback)
into a shared readDailySeries reader so the single-type route and the
batch route read through the same path with no SQL duplication; the
single route now delegates to it. Cap the batch's per-type reads with
p-limit to protect the shared pool. Sleep stays on its per-night /series
adapter and is not batched.

HealthChart gains an optional preloadedSeries prop: when the dashboard
hands it a covered slice for the daily window it folds those rows instead
of self-fetching, keyed so a range-tab change re-fetches cleanly. Non-
dashboard mounts are unchanged (empty discriminator keeps the cache tuple
byte-identical). Register the endpoint in the OpenAPI table and regen the
spec; add unit + parity coverage for the batch route.
2026-06-18 15:29:20 +02:00
Marc-André Bombeck bd80557749 perf(dashboard): compute chart band/target math server-side
Move the BP/pulse/body-fat/weight band + target derivation out of the
dashboard client component and into the snapshot DTO. The page recomputed
every band from the user profile on each render via the analytics band
helpers; the snapshot builder now resolves the same numeric structures
once server-side under a new targetBands block, calling the identical
helpers. Emit only the i18n-free numbers — the label-bearing BP target
zones stay client-assembled from the server numbers.

Add a parity test asserting buildTargetBands matches the prior client
derivation for a male, a female, and a no-profile user.
2026-06-18 15:29:09 +02:00
Marc-André Bombeck b5b557f84d feat(pwa): real offline reads — SW stale-while-revalidate allowlist + IDB query persister + offline indicator 2026-06-18 15:19:16 +02:00
Marc-André Bombeck bb08aa42dc feat(pwa): real offline read value via SW data cache + query persistence
An installed PWA opened offline rendered empty skeletons forever: the
service worker hard-excluded every /api/* path and there was no client
query persistence. Close both gaps for read value.

Service worker: add a stale-while-revalidate branch for an allowlist of
safe GET /api/* reads (dashboard snapshot + widgets, measurements,
medications, insights, analytics, version) in a release-versioned
healthlog-data cache. Two-layer refusal — a path deny guard for any
auth/token/session/login segment plus a body-level secret refusal for
hlk_/hlr_/sk- shapes and no-store responses — so a secret-shaped body or
an auth surface can never be persisted. Mutations stay network-only.

Client: add a dependency-free IndexedDB query persister that hydrates the
last successful reads before the first authenticated paint, build-version
and age gated, debounced on cache change, with an auth/session/admin/token
denylist. Cleared on logout alongside the SW page and data caches so a
shared device never leaks one account's data.

Offline banner now states it is showing last-synced data when a cached
snapshot is present. Bump release to v1.18.6.
2026-06-18 15:18:48 +02:00
Marc-André Bombeck ba89f97176 fix(mobile): touch-target floor, reduced-motion backstop, manifest categories, trivy ignore for npm-bundled picomatch 2026-06-18 15:16:23 +02:00