Commit Graph
3886 Commits
Author SHA1 Message Date
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 08d25e1cf0 feat(pwa): richer install metadata in the web manifest
Add categories (health, medical, lifestyle) and extend display_override
to allow minimal-ui as a fallback. Maskable icons were already present.
Screenshots are deferred until narrow + wide capture assets exist.
2026-06-18 15:15:14 +02:00
Marc-André Bombeck 722af87382 fix(a11y): add a global prefers-reduced-motion backstop
Per-component motion-reduce gating stays in place with its tailored
static fallbacks; this adds a defensive catch-all so any animation or
transition that misses a per-component guard still collapses to near-
instant under reduced motion. Near-zero rather than zero keeps
animation/transition-end listeners firing so state machines don't
stall.
2026-06-18 15:15:14 +02:00
Marc-André Bombeck 1eb143fad4 fix(a11y): raise sub-44px tap targets to the mobile floor
Bring interactive elements that fell below the 44px iOS/WCAG touch
floor up to min-h-11 on phones (relaxing to the denser sm density on
larger screens), and add visible focus rings where they were missing:

- TimesOfDayChips remove button + API-token copy buttons gain an
  inset hit-area so the visual density stays tight while the tap
  target clears the floor.
- Dose-history row-actions menu trigger and the insights suggested-
  prompt chips raise to min-h-11 sm:min-h-9.
- Sortable table headers and the glucose advanced disclosure toggle
  gain a focus-visible ring and a phone tap floor.
2026-06-18 15:15:01 +02:00
Marc-André Bombeck e0796c5e44 feat(search): numeric value-range filter (valueMin/valueMax) 2026-06-18 14:42:52 +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 e6ea27baf2 feat(medications): collapsible advanced groups + injection titration timeline
Restructure the medication detail advanced surface (Erweitert + API tabs)
into expand/collapse groups: SettingsGroup gains an opt-in collapsible
mode built on the same Radix Collapsible primitive SheetSection uses, so a
long settings wall reads as scannable sections. Lifecycle and external-API
groups default open; Data and the destructive zone default collapsed.

Add a titration timeline to the injection tab — completed and planned
dose-escalation steps rendered as a vertical timeline with a "you are
here" marker. Reads the existing MedicationDoseChange stream (future-dated
rows are planned steps), so no schema change is needed. Neutral palette,
no state-driven colour.
2026-06-18 14:41:14 +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 15df54b345 feat(vorsorge): per-item medication-style cards, value-capture on complete, custom intervals, wrench + dashboard widget 2026-06-17 07:53:48 +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 89b6516803 feat(illness): clarify the condition-journal layout
Rework the illness list so each episode reads as a medication-styled card:
the header carries the label link, neutral type / status badges, and the
overflow kebab; a single bottom-pinned "Log a day" button is the one
explicit action per card. Drop the redundant chevron — the card title is
already the link.

Split the flat list into Active / Resolved groups (active first) and nest
flares under their parent condition so the parent relationship the form
captures becomes visible. Lift the retrospective summary above the list so a
long history no longer buries it.

Host the per-day timeline exclusively on the detail surface: the list rows
stay clean summaries, and navigating reveals today's logged symptoms,
functional impact, fever, and note alongside the correlation card. Neutral
palette and retrospective-only copy throughout.
2026-06-17 07:45:36 +02:00
Marc-André Bombeck 370b2f9c14 chore(cleanup): prune dead i18n keys, fix knip glob, scope Rest Mode to window
Remove 18 unreferenced i18n subtrees (75 leaves per locale, 450 strings
across de/en/es/fr/it/pl) left behind by the settings D0-D8 reshuffle and
the medication advanced/erweitert naming drift: settings.ai.disableCoach
(superseded by settings.coach.activate), doctorReport.dialog/sections, the
dead settings.sections export.hero / export.cards.doctorReport /
layout.medications / layout.mood / integrations.tabs subtrees and the
orphaned integrations connections/channels blurbs, the medications
detail.advanced.title/group + detail.edit duplicates, insights.recovery
.scoreLink, insights.range, targets.relativeDay (duplicate of
targets.consistency.relativeDay), and insights.subPage.primary /
lastMeasurement. Each subtree confirmed unused; the i18n coverage,
locale-integrity, and sections-parity suites stay green. The per-slug
settings.sections.<slug>.description keys are kept — they back the
SectionPlaceholder fallback and the parity test asserts them.

Point the stale knip entry at src/proxy.ts (renamed from middleware.ts)
and drop the no-match src/lib/health glob.

Resolve the analytics Rest Mode annotation as of the scored window: thread
a single scoredAt instant into both the health-score computation and
resolveRestMode, instead of letting the annotation read a later now().
2026-06-16 23:51:51 +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
Marc-André Bombeck cdc76620fb fix(insights,dashboard): rest-mode web banner, sleep per-chart assessments, steps explainer, recovery heading level 2026-06-16 23:20:41 +02:00
Marc-André Bombeck 201686f498 fix(insights): surface Rest Mode on the web and align sleep assessments
Add a calm, value-free Rest Mode indicator to the dashboard score card and
the recovery insight so the web matches what iOS already shows: when an
illness/condition episode is active the score and recovery are framed, never
penalised, and the surface now says so. The new banner is neutral and
untinted, self-gates on an active episode, and reveals only the onset date —
never a decrypted note or episode label. The dashboard reads the annotation
from the snapshot (resolved server-side alongside the score); the recovery
section reads it from the analytics payload.

Give the sleep page one assessment per distinct chart group: the
duration/architecture assessment now sits with its charts, and the
sleep-quality block carries its own SLEEP_SCORE assessment, matching the
"multiple charts, multiple texts" structure the recovery page meets.

Add the steps explainer (insights.subPage.explainer.steps*) and pass
explainerMetric="steps", and demote the recovery per-block title to an h3 so
the heading outline nests cleanly under the page heading and group heading.
2026-06-16 23:20:04 +02:00
Marc-André Bombeck d92af094ed fix(illness): wire P3 engine to UI + OpenAPI, correctness (return-anchor, red-flag, fever, tz), FLARE/edit, guards 2026-06-16 23:18:40 +02:00
Marc-André Bombeck 674e318ad7 fix(illness): document the correlation + insights routes, cap the coach label
Register the per-episode correlation and cross-episode insights GET routes in
the OpenAPI source and regenerate the spec so the iOS contract carries the
Derived findings shape. Cap and strip the free-text episode label before it
enters the Coach prompt — it is the one user-controlled string in the illness
context block, so collapse control and newline sequences and bound the length.
2026-06-16 23:18:06 +02:00
Marc-André Bombeck c243f6a963 feat(illness): episode detail, retrospective surface, edit + delete guard
Add an episode-detail page at /illness/[id] mirroring the lab-biomarker detail:
it renders the per-episode correlation card (recovery gap, red flags, pre-onset
and nadir findings, or 'still learning' when the signal is thin) and surfaces
the stored note. List rows link into it; the cross-episode retrospective summary
sits below the history. Fold the row's inline buttons into a shared overflow
menu and gate delete behind a confirmation dialog. Let the new-episode sheet
double as an edit form, add the FLARE course and a parent-condition picker, and
let the day-log sheet backdate within the episode window. Swap the loading and
empty states onto the shared primitives.
2026-06-16 23:18:06 +02:00
Marc-André Bombeck d7a698950c fix(illness): harden the episode + day-log write paths
Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates.
Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial
edit is checked against the stored onset). Run the day-log upsert, its symptom-
link replacement, and the read-back in one transaction and return the full DTO,
dropping the route's second round-trip. Restrict symptom severity to 1-3 (a
link's presence already means present, so 0 is not a distinct state). Return
the standard { deleted: true } envelope from DELETE instead of a bodyless 204.
2026-06-16 23:18:06 +02:00
Marc-André Bombeck 82cabff8d0 fix(illness): correct the recovery-gap and red-flag engine
Anchor the physiological-return search after the first notable deviation so
an early in-band run-up can no longer be reported as a return before the
vital ever deviated (the spurious-negative-gap bug). Decouple the red-flag
escalation from the baseline-band loop: it now scans the raw episode days for
SpO2 and temperature against absolute clinical floors, so a rock-steady
(MAD=0) vital still escalates a low SpO2 or sustained fever. Union the day-log
feverC series into the fever flag and prefer per-day max so an evening spike
is not masked by the daily mean. Key every per-day series in the user's
timezone (rollup buckets and the raw fallback) so the vital series lines up
with the tz-keyed onset and felt-better markers instead of an off-by-one for
non-UTC accounts.
2026-06-16 23:17:48 +02:00
Marc-André Bombeck e8e5ece6a1 fix(labs): auto-link biomarker on write, note-preserve, inverted-range guard, footer-pin, fail-soft decrypt, self-edit 2026-06-16 23:14:01 +02:00
Marc-André Bombeck 6d7f9834db fix(settings): drop leading blurbs, correct toggleable-medications comments, dead sources branch, ai-quality frame, low-stock gating 2026-06-16 23:12:59 +02:00
Marc-André Bombeck dadb6e2d34 fix(settings): finish the D0-D8 settings/admin frame cleanup
- Drop the leading blurb paragraph from the Channels + Integrations
  connection panels so each body starts at its first card (D0 alignment).
- Correct three stale CORE/always-on comments: medications has been a
  fail-open toggleable module since D3; CORE is now weight/BP/pulse only.
- Remove the dead variant/isSubtab branch from SourcesSection — no callers
  after the D4 split; render the standalone header unconditionally.
- Render the admin AI-quality card frame + header once and branch only the
  body across loading/error/empty/data, matching the coach-feedback card.
- Rename the Coach activation card's section id from the legacy
  settings-ai-* namespace to settings-coach-activate-* after the S5 move.
- Gate the medication low-stock reminder card on the medications module so
  it hides when the account turns medications off, mirroring mood/coach.
2026-06-16 23:12:36 +02:00
Marc-André Bombeck fee26254b2 fix(labs): surface truncation hint, drop dead code, document module gate
Add a 'showing latest N of M' hint to the lab list and biomarker detail
when the 500-row cap truncates. Drop the inaccurate amend comment from the
edit history, justify the inline-SVG vs Recharts sparkline split, remove
the unused BiomarkerPanelKey export, and document the deliberate UX-only
(non-server) module gate on the labs + biomarkers routes. New labs i18n
keys added across all six locales.
2026-06-16 23:12:12 +02:00
Marc-André Bombeck 9c8c7051aa fix(labs): pin add-entry action bars to the sheet footer; tighten header
The lab and biomarker forms portal their Cancel/Save row into the
ResponsiveSheet sticky footer (footerSlot pattern) so Save stays reachable
on mobile instead of scrolling away. The Labs header folds Manage
biomarkers into an overflow kebab beside the primary Add, mirroring the
medication header. The biomarker detail gains a self-edit Pencil opening
the pre-filled form. Add buttons on the labs detail and measurements page
meet the min-h-11 sm:min-h-9 tap floor.
2026-06-16 23:12:04 +02:00
Marc-André Bombeck 5514271668 fix(reminders,coach): BYHOUR multi-time, vorsorge edit + sheet, COACH label i18n, TOCTOU, origin/endsOn UI 2026-06-16 23:11:55 +02:00
Marc-André Bombeck 6df0c64d85 fix(labs): fail-soft the biomarker list context decrypt
The catalog GET eagerly threw on the first un-decryptable context row,
500ing the whole list. The list serializer now decrypts fail-soft (null
on a bad-key/malformed row) via decryptContextSoft, bounding the
fail-closed blast radius to one marker; the single-resource GET stays the
authoritative throwing read for the plaintext context.
2026-06-16 23:11:54 +02:00
Marc-André Bombeck 9141b8f74f fix(labs): guard partial bound edits against an inverted range
A PUT that moves a single reference bound past the row's stored other
bound slipped through the schema refine (which only fires when both bounds
arrive together). Both the lab-result and biomarker routes now merge the
effective bounds and 422 on low > high. Editing catalog-resolved fields
(analyte/unit/panel/range) on a linked reading is also rejected rather than
silently no-oped. Shared isInvertedRange/effectiveBound helpers with tests.
2026-06-16 23:11:48 +02:00
Marc-André Bombeck d1fde96e66 fix(labs): preserve a reading's note when its decrypted load fails
The edit sheet loaded the decrypted note from the single-resource GET and
sent note:null on a load miss, silently wiping a note that simply could
not be read. The submit path now omits the note key entirely in that case
(server preserves the stored ciphertext), disables the textarea, and shows
a hint. Decision encoded in a pure resolveNoteForUpdate helper with tests.
2026-06-16 23:11:35 +02:00
Marc-André Bombeck 796f6bacd6 fix(labs): auto-link lab readings to a biomarker on write
A free-text lab write resolves or mints a user-scoped biomarker by
(userId, lower(analyte)) so no LabResult row ever persists unlinked. An
existing marker is reused as-is (its unit/range stay authoritative); a
miss mints one from the reading's own spelling. Every reading is now
editable and detail-navigable, and the boot-time backfill becomes a pure
pre-upgrade migration. Closes the unlinked-row dead-end and the contract
hole in one path.
2026-06-16 23:11:28 +02:00
Marc-André Bombeck 7451e886f1 fix(vorsorge,coach): align reminders + coach chrome with the gold standard
Twice-daily BP protocol (RRULE BYHOUR=7,19) now fires at every clock
hour: byHourTimesOfDay expands BYHOUR into the engine's timesOfDay so
the label, RRULE, and engine output agree instead of collapsing to one
nudge at notifyHour.

Vorsorge surface:
- move create/edit into a ResponsiveSheet with a pinned Cancel/Save
  footer, matching Labs/Illness/Medications
- wire the unused PATCH support: Edit affordance opens the form
  pre-filled; an enable/disable Switch drives the enabled flag
- translate COACH-origin labels (the row stores an i18n key, not prose)
  and render a neutral Coach provenance badge + an until-<date> line
- surface origin + endsOn on the hook type
- pull-to-refresh + always-on rationale documented on the nav entry
- NativeSelect-vs-Radix choice documented as deliberate

Coach:
- expired COACH course-window reminders self-clean: the cron tick
  soft-deletes finished finite cadences instead of leaving dead rows
- accept-dedup gains a partial unique index (user, metric) where
  origin=COACH and not deleted, with a P2002 catch in the route
- per-user rate-limit bucket on the suggestion-action endpoint
- SUGGEST-REMINDER parser keeps prose written after the block
- document POST /api/coach/reminder-suggestions in OpenAPI

Coach chrome:
- HistoryRail hideHeading prop drops the duplicate Conversations
  heading on the page surface
- align the rail-tray strip inset to px-3 sm:px-4 so the dividers line
  up; reword the FAB inset comment

satisfyReminder closes the forward-only TOCTOU via a conditional
updateMany (count===0 no-op). Migration headers corrected to v1.18.1.
2026-06-16 23:11:08 +02:00
Marc-André Bombeck 88c4a8428f fix(admin): harden the Danger-Zone wipe to match the Backups Restore gate
The global data wipe auto-supplied confirm:'DELETE ALL' from a single-click
dialog, leaving the server's typed-token defence dead and making the most
destructive action a weaker gate than the less-destructive restore. Mirror
RestoreRowDialog: require typing 'DELETE ALL' into an Input that arms the
destructive button, reset on close.

Also attach an Idempotency-Key header to the wipe so a double-submit cannot
re-run the transaction, add the documented post-requireAdmin()
'admin-data-delete:<userId>' rate-limit bucket alongside the anonymous IP
throttle, and switch BackupsSection off the non-reactive formatDateTime
onto the locale-reactive useFormatters() hook its siblings use.
2026-06-16 23:05:11 +02:00
Marc-André Bombeck e23f57448a fix(crypto): wire every encrypted column into key rotation behind a registry guard
The rotation script omitted the four v1.18.1 clinical-spine note columns
(LabResult.noteEncrypted, Biomarker.contextEncrypted,
IllnessEpisode.noteEncrypted, IllnessDayLog.noteEncrypted) plus a longer
pre-existing tail (Nightscout/Polar/Oura credentials, the KVNR field, the
health-profile and cycle-day-log payloads, InsightNarrative content).
Rotation reported zero legacy rows while these still held old-key
ciphertext, so dropping the legacy key left them permanently
undecryptable (decrypt is fail-closed).

Introduce src/lib/crypto/encrypted-columns.ts as the canonical registry of
every reversibly-encrypted column, drive the rotation script from it, and
add a guard test that cross-checks the registry against every encrypted
column in schema.prisma and asserts the script references each one. A new
*Encrypted column not wired into rotation now fails CI by construction.
2026-06-16 23:05:03 +02:00
Marc-André Bombeck b28fa4d4a5 merge: suppress-during-illness Rest Mode + FHIR Condition + doctor-report 2026-06-16 22:27:13 +02:00
Marc-André Bombeck c59cf0b652 feat(illness): Rest Mode — annotate score/recovery/streaks during an active episode + FHIR/doctor-report
An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.

- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
  for "is the account in Rest Mode + which episodes back it". iOS mirrors the
  resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
  computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
  scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
  skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
  day-streak across illness days; never invents a streak, a non-illness gap
  still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
  numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
  the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
  code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
  (retrospective, neutral palette); FHIR Condition section in the bundle.

i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
2026-06-16 22:25:50 +02:00
Marc-André Bombeck 6a74025b0a merge: illness correlation + recovery-gap engine with reliability battery 2026-06-16 22:17:29 +02:00
Marc-André Bombeck 745fa28c8b feat(illness): correlation + recovery-gap engine
Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.

The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.

Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
  Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
  typical recovery gap X days"), with the typical-gap withheld below the
  min-sample floor. Retrospective only — never a predictor or diagnoser.

Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
2026-06-16 22:16:19 +02:00
Marc-André Bombeck 800e799d48 merge: extract worker lifecycle handlers + register illness CRUD in OpenAPI 2026-06-16 21:57:58 +02:00
Marc-André Bombeck c1d804809d docs(openapi): publish the illness episode + day-log CRUD contract
Register /api/illness/episodes (list + create), the {id} resource
(read + edit + soft-delete), the {id}/resolve verb, and the
{id}/day-logs upsert in the OpenAPI route table, and add the Illness
tag. Request bodies + queries reuse the runtime Zod schemas from
validations/illness so the wire contract stays single-source; the
response shapes mirror the server-authoritative episode + day-log DTOs.
Regenerated docs/api/openapi.yaml; openapi:check is in sync.
2026-06-16 21:56:54 +02:00
Marc-André Bombeck 042510d1d4 merge: flatten the dashboard hero to match the chart-card design 2026-06-16 21:55:34 +02:00