Commit Graph
2687 Commits
Author SHA1 Message Date
Marc-André Bombeck 6614e0646f merge: Coach v1.18.6 nudge and conversation continuity
Add the Coach proactive nudge-status surface and last-seen tracking
(migration renumbered to 0176) plus the seen endpoint.
2026-06-18 22:04:31 +02:00
Marc-André Bombeck d1355a2999 merge: sleep, insights and recovery surfacing
Suppress the synthesized sleep timeline for non-measured nights while
keeping measured-night detail, and refine the insights and recovery
surfacing.
2026-06-18 22:04:06 +02:00
Marc-André Bombeck 70f92558e6 feat(reminders): add server-side completion route for Vorsorge reminders
Add POST /api/measurement-reminders/{id}/complete so a client can mark a
measurement reminder done on the server, rather than only dismissing the
notification locally. The route is the explicit user-action equivalent of
the cron auto-resolve: it routes through the one shared satisfaction
primitive, stamps lastSatisfiedAt, re-anchors the server-authoritative
nextDueAt, and fires no notification of its own.

Idempotent via the primitive's forward-only guard — completing an
already-completed or auto-satisfied reminder returns 200 with
completed=false so a repeat tap need not be special-cased. Owner-scoped;
a cross-user or tombstoned id 404s and never satisfies. No schema change:
lastSatisfiedAt already records the completion instant.

Extend the OpenAPI contract with the new path and the completion result
schema, and cover the route with unit tests (marks done, idempotent
no-op, owner-scoped 404, no double-notify).
2026-06-18 21:39:24 +02:00
Marc-André Bombeck 0a8ccaa30c feat(onboarding): one-time medical-disclaimer acknowledgment
Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.

- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
  migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
  /api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
2026-06-18 21:37:13 +02:00
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 34fbfe1c67 feat(insights): link the overview Trends cards to their detail pages
The overview charted each trend but never offered a way into the metric's
detail page — the steps page in particular exists and is used, yet Insights
never linked to it. Give every Trends-row chart a routed detail target and a
discreet "view details" link below the caption, so a metric the row surfaces
(steps included, when the briefing flags it) drills into /insights/<slug>.
2026-06-18 21:24:02 +02:00
Marc-André Bombeck 3a3f88a80c feat(coach): mark an unread proactive nudge on the Coach launcher
Add a discreet unread dot to the Coach FAB when the Coach has something
the user has not opened yet, and clear it when they open the Coach.

The signal is server-authoritative: a new coach_last_seen_at column on
users records the last time the Coach was opened, and the launcher's
status endpoint reports unread when the newest assistant message is
newer than that stamp — so the dot works even with no push channel and
clears across every device, web and iOS. Opening the Coach (FAB or the
/coach page) stamps the column via a new /api/insights/coach/seen
endpoint; the launcher keeps a local stamp only for an instant paint.

The dot is a calm cyan, not an alarming red, per the card-status rule.
2026-06-18 21:19:27 +02:00
Marc-André Bombeck f453f8a758 fix(coach): surface the proactive nudge in the conversation rail
The proactive Coach nudge dispatched a notification only — it never
wrote a CoachMessage, so it was invisible under the conversation list
and entirely silent for a user with no push channel configured.

Record the nudge as a real conversation before dispatching: create a
conversation and write the nudge body as the initial assistant message
(encrypted at rest like every other Coach reply), then fire the existing
notification + /coach deep link unchanged. A persistence failure is
caught locally so it can never swallow the notification, and the tick
summary tracks a separate persisted count alongside dispatched.
2026-06-18 21:19:17 +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 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 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 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 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 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 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 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 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 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 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 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 cc89695cbd feat(measurements): value-range filter on the management list
Add an optional numeric value-range filter to the measurement list:
readings whose `value` falls within `[valueMin, valueMax]`, either bound
omittable for an open-ended range. Server-authoritative — both bounds are
parsed on `listMeasurementsSchema` (coerced from the query string), an
inverted `valueMin > valueMax` returns 422, and the bounds thread into the
Prisma `where` as a `value: { gte, lte }` range on the raw per-sample list.

The filter rides a new `FilterBarNumberRange` pill that mirrors the existing
date-range control, hidden for the day-grouped and sleep-night types where
the painted value is a synthesised total rather than a stored reading.
2026-06-18 14:42:29 +02:00
Marc-André Bombeck 604aec631f feat(notifications): strengthen the Web Push path for PWA-only self-hosters
Self-hosters without an Apple Developer account rely on Web Push to the
installed PWA — there is no Live Activity or lock-screen widget to drive.
Make that path carry the dose lifecycle on its own:

- Stable per-slot tag. The medication dose-due reminder now sets a
  `med:<id>:<iso-slot>` Web Push tag (medicationDoseTag), so a re-fired
  reminder for the same slot coalesces instead of stacking, and a later
  clear can target it.
- Clear-on-taken. When a dose is marked taken or skipped, push a
  `type:"clear"` notification reusing that slot tag. The service worker
  closes the matching pending reminder and refreshes the badge — the PWA
  equivalent of ending a Live Activity. A snooze leaves the dose
  outstanding and clears nothing. Wired into the single + bulk intake
  routes alongside the existing APNs cross-device sync.
- App badge. The dispatcher carries a server-authoritative outstanding-
  dose count; the SW reflects it via the Badging API
  (setAppBadge/clearAppBadge), feature-detected and a no-op where
  unsupported.
- The SW push handler honours requireInteraction for urgent events and
  renotify so a re-fired reminder re-alerts.

Dose-due, preventive-care, and low-stock reminders already cascade to
Web Push when no APNs channel exists; tests pin that an APNs-less
instance still delivers.
2026-06-18 11:39:55 +02:00
Marc-André Bombeck 6c070abfdc feat(notifications): escalate urgent events to each channel's highest tier
Add an urgent dimension to dispatched notifications so a self-hosted
instance reaches every configured channel at its strongest level without
assuming APNs is present:

- APNs    -> interruption-level time-sensitive + priority 10 (critical
  only when APNS_CRITICAL_ENTITLEMENT=true; time-sensitive needs no
  Apple approval)
- ntfy    -> Priority 5 (max) + warning tag (suppressed in discreet mode)
- Web Push-> Urgency: high send option + requireInteraction
- Webhook -> priority "urgent" in the JSON body
- Telegram-> normal delivery (no urgency tier)

Wire the illness red-flag escalation (sustained low SpO2 / sustained
fever) to emit an urgent SYSTEM_ALERT through the dispatcher, owner-scoped
and module-gated, de-duplicated per episode via the push-attempt ledger.
Normal events are unchanged.
2026-06-18 11:28:18 +02:00
Marc-André Bombeck 4cce49374f feat(illness): soft-delete restore route + undo toast 2026-06-17 14:15:44 +02:00
Marc-André Bombeck 55e7835ef4 feat(illness): restore a soft-deleted episode via delete-Undo
Add POST /api/illness/episodes/{id}/restore: clears deletedAt so the
episode and its preserved day-logs and encrypted note re-surface in
every read. Soft-delete keeps the row and its children intact, so
restore is a pure flag flip rather than a re-create. apiHandler +
idempotency + module gate + owner-scoped 404; restoring a live episode
is a no-op.

Wire the episode-menu delete to a restore Undo toast, mirroring the
labs delete-Undo affordance. Register the route in the OpenAPI source
and regenerate the contract.
2026-06-17 14:15:12 +02:00
Marc-André Bombeck 6ba2d435c9 feat(modules): illness defaults on (remove born-gated mechanism) 2026-06-17 14:11:30 +02:00
Marc-André Bombeck bc94a5893f feat(modules): default the illness journal on
The illness module shipped opt-in/default-off via a bornGated flag, the
inverse of the standard disabled-allowlist. Make it a normal optional
module that defaults on (opt-out), matching labs and its siblings.

No module used bornGated other than illness, so drop the flag, its
isBornGatedModule helper, and the gate resolver's born-gated branch
entirely. The module stays a normal toggle under What you track and the
operator-level availability override is unchanged; an account that wants
it off sets an explicit false. Refresh the gate, doctor-report, labs and
biomarkers comments plus the OpenAPI route descriptions accordingly.
2026-06-17 14:10:46 +02:00
Marc-André Bombeck fcd498d252 fix(inventory): serialise unknown units as null, not 0 (iOS shows unknown, no phantom negatives) 2026-06-17 14:09:42 +02:00
Marc-André Bombeck b132244c03 fix(medications): serialise unknown inventory as null, not 0
An inventory item's unit count comes from a non-null Decimal column, but a
corrupt or legacy row could hold a missing or non-finite value. The
serialiser ran `Number(item.unitsTotal)`, and `Number(null) === 0` (and
`Number("corrupt") === NaN`), so an unknown count went out on the wire as a
fabricated 0 that a client could decrement into negatives.

Serialise a genuinely-unknown count (null / non-finite) as `null` so clients
render "unknown" instead. A real tracked 0 stays 0. No model change — the
distinction is serialisation-only.

The supply tab now renders an unknown-units row as "—", pre-fills the
correction editor empty, drops the capacity ceiling when total is unknown,
and keeps such a row out of the pooled available-supply headline. The
low-stock alert already excludes non-usable rows, so it does not fire on
unknown stock. OpenAPI marks both unit fields nullable.
2026-06-17 14:09:04 +02:00
Marc-André Bombeck cdf43c3669 feat(illness): add date-less day-log list and full-history timeline
The episode day-logs endpoint only accepted a single `date`, so the
detail timeline could anchor on today alone. Add a date-less LIST variant
to GET /api/illness/episodes/{id}/day-logs: when no `date` is given,
return the episode's day-logs newest-first with limit/offset/sortDir
paging and meta.total (mirrors the Labs list envelope). The single-date
read stays intact, selected by the param's presence.

Wire the detail timeline onto the list for a full historical scroll
instead of today only, keeping the neutral palette and retrospective
copy. Register the list response in the OpenAPI contract for the iOS
client.
2026-06-17 14:07:16 +02:00
Marc-André Bombeck cba3c0c94f feat(vorsorge): rebuild preventive care to mirror the medication module
Each reminder is now its own med-styled card reusing the medication card
primitives (MedicationCardHeader / MedicationNextLastSlot) on a neutral
surface with a single bottom-pinned action and a kebab carrying edit +
delete. The mark-done action branches on the linked measurement type:

- a free-text / self-planned exam keeps the silent satisfy ("Done");
- a measurement-linked reminder opens the real measurement-entry form
  ("Log value") pre-filled with its type, and on a successful write
  satisfies the reminder in the same action (forward-only guard keeps
  the cron a no-op).

The create/edit sheet gains a first-due date (anchorDate), a free
custom interval, and an RFC-5545 RRULE option, all already accepted by
the create/update schema. A wrench/customize affordance beside the page
Add button re-homes the per-reminder enable toggles moved off the card.

Vorsorge also joins the dashboard grid layout like medications: a new
chart-row summary card listing the next few due reminders with inline
done/capture buttons, default-off so existing dashboards are unchanged.

No migration and no API schema change; OpenAPI stays in sync.
2026-06-17 07:52:48 +02:00
Marc-André Bombeck 53dc7f76b5 refactor(jobs): decompose reminder worker into domain registrars; extract achievements result builder 2026-06-16 23:29:46 +02:00
Marc-André Bombeck 03034339d6 refactor(gamification): extract achievements builder out of the route
Move the day-series, completion-date, and streak helpers plus
buildAchievementsResult and the hidden-card spoiler shields into
src/lib/gamification/achievements-result.ts (verbatim). The route drops
from 1247 to 136 LOC and is now auth -> module gate -> cached ->
idempotent unlock-persistence -> apiSuccess (web + iOS format).

No behaviour change: the aggregation moved as-is, the cached factory
contract and the off-cache pendingUnlocks persistence are unchanged.
2026-06-16 23:28:55 +02:00
Marc-André Bombeck 9bd46ff25d refactor(jobs): decompose reminder-worker into domain queue registrars
Split the 2143-LOC startReminderWorker boot file into five domain
registrars under src/lib/jobs/reminder/ (integration-sync, status,
rollup, reminders, maintenance), each owning its queue-name constants,
its allQueues membership, its schedule tuples, and its boss.work
bindings. A shared createAndSchedule helper provisions every queue
before scheduling it. The boot file is now a 115-LOC registration table
that composes the registrars and runs the boot one-shots + discovery
enqueues.

Every queue declaration, schedule, boss.work binding, and boot-discovery
enqueue is preserved verbatim — verified by diffing the old monolith
against the union of the registrars (66/66 queues, 66/66 bindings,
identical schedule tuples and boot enqueues). The diff caught one
withings-oauth-state-cleanup binding that was dropped mid-refactor and
restored it.

Update the 18 queue-registration guard tests to follow the wiring into
its registrar module so the dead-queue protection moves with the code
rather than weakening: each grep guard now reads its queue's registrar,
the stress-strain-retention guard unions the allQueues blocks across the
status + rollup registrars, and the intake-slot-dedup schedules regex
tracks the new module-level indentation.
2026-06-16 23:28:48 +02:00