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.
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.
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).
Correct the accreted drift in CLAUDE.md against the current tree: 61→80 Prisma
models (tech table + schema row), the React 19.2.x exact pin (drop the stale
patch digit), and the v1.12.x line marker → v1.18.x. Re-point the two paths
that are now directories — `src/lib/openapi/routes/` (23 modules) and
`src/lib/query-keys/` (16 modules), both with `index.ts` barrels. Replace the
exact line anchors that have since moved (requireAdmin, the rollup
whitelist-splice, isPublicUrl, the Dockerfile pg-boss/prisma-cli install,
api-handler LOC) with symbol references that survive line drift, fix the AI
providers row to cite `provider.ts`/`resolveProvider` plus the
`PROVIDER_CHAIN_TYPES` allowlist, and correct the `apiSuccess(data, status?)`
signature in the request-flow diagram.
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.
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.
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.
startOfLocalDayInTz DST-safe day anchor (fixes the intake UTC-midnight
off-by-offset bug) + userDayKey/wallClockInTz dedup across scheduling,
consolidation and correlations; illness-insights N+1 collapsed under a
concurrency cap; series-batch per-type isolation with degraded_types
meta; rest-mode episode read memoised per request; targets response
builder extracted to src/lib/targets.
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.
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.
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.
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.
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.
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.
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.
The production build registers a service worker whose v1.18.6 data branch
serves allowlisted GET /api/* reads network-first. A worker-originated
fetch is not subject to Playwright's page.route interception, so the
worker re-fetched the real (empty) backend and bypassed every per-spec
route.fulfill — a just-created row never surfaced and read-after-write
assertions failed. Block the worker in the test context so the route
mocks are authoritative; the shipped offline cache is untouched for real
users. Separately, the dashboard now batches chart series into
/api/measurements/series-batch, a sub-path the **/api/measurements* glob
does not match, so it fell through to the empty backend and each chart
painted an empty preloaded slice; stub the endpoint with the same
populated set.
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.
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).
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.
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.
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.
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.
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.
CVE-2026-33671 (picomatch ReDoS, HIGH) is flagged only in the npm CLI's
own bundled copy inside the Node base image, used at image-build time,
not by our runtime. Our app dependency tree pins picomatch >= 4.0.4 via
a pnpm override, so the running app is not affected. Re-check when the
base image ships an npm whose bundled picomatch is >= 4.0.4.
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.
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.
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.
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.
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.
Cover the full channel set self-hosters can run without an Apple account:
Web Push, Telegram, ntfy, the generic webhook, and email over SMTP, plus
the dispatcher cascade and graceful degradation when APNs is absent
(ntfy max priority, Web Push high urgency, webhook urgency flag).
Document the PWA path as the recommended default: install to the Home
Screen, enable Web Push, and get reminders, the taken-clears-the-reminder
behaviour, and the outstanding-dose app badge. Note the inherent limit
that the lock-screen Live Activity needs a native build.
Explain why native iOS push binds to the publisher's Apple team and walk
through running your own signed build under your own bundle ID, team, and
.p8 key, with the APNS_* env vars including APNS_CRITICAL_ENTITLEMENT.
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.
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.