Commit Graph
1657 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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