Reading localStorage/matchMedia in a mount effect tripped the
react-hooks/set-state-in-effect rule. Model the stored preference and the
OS preference as external stores read via useSyncExternalStore with an
SSR-stable 'dark' server snapshot, and apply the <html> class from a
DOM-only effect — no setState-in-effect, same hydration-seam fix.
Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.
Co-Authored-By: Marc-André Bombeck
The Coach self-context loader failed soft per field (undecryptable
allergies/conditions → null) with no signal, so a key-rotation gap made
the Coach generate guidance without ever seeing the user's recorded
allergies, indistinguishable from an empty profile. Keep the per-field
fail-closed posture but emit a wide-event on each swallowed decrypt and on
the DB-read guard, so a systemic failure surfaces instead of masquerading
as 'the user never wrote anything'.
- Onboarding 'more sources' chips anchor to their provider card
(/settings/integrations#oura etc.); connection cards gain scroll anchors.
Apple Health (no web card) stays on the page root.
- Cycle module gains a header settings wrench for parity with labs /
medications / illness; the tab strip is now controlled so the wrench jumps
to the settings tab and ?tab= deep-links keep working.
Withings backdates some readings and lastSyncedAt advances on every
success (including a healthy 200-with-0 cycle), so a reading landing more
than 60s before the next cycle's now() could be missed. Widen the poll
catch-up overlap to 10 minutes — the upserts are idempotent, so re-touching
a handful of rows is harmless.
Freshness was tracked only per-integration, so a provider returning a
healthy 200 with an empty body for ONE metric type recorded no failure and
the whole integration stamped green — a metric dead since launch was
indistinguishable from a healthy-idle one. Compute the last-value
timestamp per (source, type) straight from the Measurement table (no
migration) and surface it per integration on the status endpoint, so the
Settings card can flag a silently-dead metric the green pill hides. The
card UI badge + per-cadence staleness classification are the deferred
remainder.
The fail-closed crypto loader throws a stack trace on a missing/short
ENCRYPTION_KEY rather than a sentence an operator can act on, and
check-env is a dev/CI tool the image operator never runs. Add a boot-time
readiness summary (instrumentation register) that prints a clear line per
core secret — with the exact openssl fix on a bad one — before anything
throws, plus a one-glance status of the optional channels and the
Secure-cookie transport hint. Never logs secret values; never throws.
- CustomMetricList load error uses QueryErrorCard with retry; post-create
navigation switches from a full-page window.location.assign to router.push.
- CustomMetricDetail + medications list swap their hand-rolled error blocks
for the shared QueryErrorCard.
- Side-effect delete now uses the themed AlertDialog confirm instead of a
native window.confirm.
- Admin SettingsToggle: name the Radix switch via aria-label so it no longer
announces as a bare 'switch, on/off' (one shared component, six call sites).
- Replace the phantom text-destructive-foreground / text-warning-foreground
utilities with the vetted destructive pattern (bg-destructive text-white
dark:bg-destructive/60) and a readable foreground on the warning wash — no
more dead classes riding on cascade order.
- Drop the sub-AA opacity tiers: lab sparkline + titration marker off
muted/70, state icons (query-error, chart empty/error, med sort) off
muted/60 to the full token so they clear the light-theme floor.
- Chart range toggles expose aria-pressed (lab, custom-metric, health, mood,
compliance) so the active window is conveyed to assistive tech.
Mutations ran in TanStack's default 'online' network mode, which PAUSES a
write fired offline: isPending stays true forever, onError never fires,
and the paused mutation is dropped on reload — a health entry vanishes
with no 'not saved' signal. Switch mutations to 'always' network mode so
an offline write rejects immediately and the form surfaces an honest
failure, and add a global OfflineMutationToaster backstop for call sites
without their own onError. Correct the offline banner copy, which promised
changes would auto-save.
Registration stays open after the first admin so a household can be
invited, but on an internet-exposed instance that means anyone who can
reach the URL can sign up — and nothing prompted the admin to close it.
Surface a muted hint under the registration toggle whenever it is open
(no behaviour change), and note the reality in getting-started's
first-user section.
- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today,
a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary
days stay 24h, and a negative-offset zone anchors on the local day.
- dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day
doses land in ONE bucket where the old fixed-Berlin key split them.
- classifyIntakeTiming: an 08:00-local dose reads on_time with the user's
zone and very_late without it.
- hourInTz: reads the wall-clock hour in the given zone.
- hero-strip greeting: resolves the hour in the stored zone, not the
browser clock; existing greeting fixtures re-pinned to explicit UTC
instants mapped to Berlin wall-clock.
Repoint suggest/summary/extract/index/reindex routes and the content-index
backfill job to the document-scoped resolvers (local-first, codex last) and the
pick-based document-egress consent gate. Labs OCR and Coach/insights untouched.
Co-Authored-By: Marc-André Bombeck
Theme provider now initializes to the SSR-stable default and reconciles
the stored preference in a mount effect instead of reading localStorage
in the useState initializer — closes the same React #418 hydration seam
the sidebar already fixed (the inline script still handles the pre-paint
class, so no FOUC). Add a process-wide Intl formatter cache and route the
per-row workout/medication list helpers through it so a formatter is
constructed once per (locale, options) rather than once per row per
render.
Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no
autocomplete token, so PasswordInput now defaults to autoComplete=off plus
data-bwignore / data-1p-ignore / data-lpignore for them — Bitwarden /
1Password / LastPass no longer offer to fill or save a config secret.
Credential fields (login, register, password change) pass a real token and
keep manager integration untouched. One change covers every secret call site.
The clinician-facing export failed SOFT on an undecryptable allergy
reaction (→ null, rendered as a bare '—' indistinguishable from 'no
reaction recorded') while a sibling dose-change note fails CLOSED. Extract
a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from
'unset', log the swallowed decrypt, and render an honest localized marker
in the PDF so a clinician is never shown a silent blank where an
unreadable reaction exists. Read-path only — the fail-closed write posture
is untouched.
Add a document-class provider resolution that demotes external/train-by-default
providers below local (local -> BYOK no-train API -> operator key -> codex last)
and a pick-based document-egress consent gate that requires an active receipt
for any external provider. Coach/insights keep the app-wide cost-first order.
Co-Authored-By: Marc-André Bombeck
Measurements were already paged, but the medication-intake, mood, and
cycle tables in the same per-user payload were still unbounded full-table
findMany reads — peak heap tracked the largest tenant's history. Give
them the same id-keyset page walk (full row projection, so payload bytes
are unchanged) so at most one page is resident per round-trip.
- Achievements: a failed load renders QueryErrorCard instead of falling
through to zeroed summary tiles and a 'none unlocked yet' empty grid.
- /coach/conversations: a load failure now shows a retry instead of a false
'no conversations yet' empty state.
- Insights overview error uses the shared QueryErrorCard.
- Coach Plans gains a header cross-link from /coach/conversations (and back),
a skeleton loading state, and the shared EmptyState primitive.
The CSV importer ran a serial prisma.upsert/create per accepted row, so
an N-row file cost N round-trips and a few-thousand-row import could
approach the request timeout. Adopt the /measurements/batch shape:
pre-fetch existing rows under the IMPORT source in one query per
dedup-key shape (externalId and natural-key paths), partition into bulk
createMany + grouped updateMany, and reconstruct the per-line
inserted/updated/duplicate envelope from the partition. In-file repeats
and re-import idempotency preserved; response contract unchanged.
The daily-series reader only fell back to live SQL on a coverage MISS
(zero rollup rows); a transient rollup-table error (statement_timeout,
deadlock, connection reset) was unwrapped and 500'd the tile on the
single-type route even though live SQL could serve it — and diverged from
the batched series route, which already caught. Wrap the primary daily
read and the long-range tiered read: treat a throw as a coverage miss and
fall through to the live date_trunc aggregate.
Fitbit's handleCollectionFetchError soft-skipped a 403 but RETHREW any
other hard failure, aborting every endpoint ordered after the bad one in
the same resource and stalling the watermark so the whole integration
stayed dead. Record hard failures on a per-cycle AsyncLocalStorage ledger
and return 0 instead of rethrowing — the sibling endpoints keep fetching,
and the ledger still fails the cycle so success is not stamped and the
next tick refetches. Also wrap the per-metric map fan-out in the same
per-collection catch so one malformed point can't skip its siblings.
- The 'no channels' CTA now points at Settings → Integrations#channels
(where channels are actually configured since v1.25.7) instead of the
reminder-types screen, which had become a dead-end.
- A failed load renders the shared QueryErrorCard with Retry; the loading
frame reserves a matrix skeleton instead of a lone spinner.
- The globally-disabled hint reads as a full sentence rather than the bare
'(Disabled)' inline tag.
- The three notification surfaces (per-event matrix, reminder types,
delivery channels) now cross-link each other.
- noChannels copy names its destination.
Five timezone-correctness fixes across the medication / compliance surfaces:
- getUserTodayBounds now derives both bounds through the DST-safe
startOfLocalDayInTz, so the today window is 23h on spring-forward and
25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
local dose no longer drops off the med cards, dashboard, PWA badge and
reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
per-day rate with userDayKey, so a non-Berlin user's evening dose is
filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
the achievements perfect-day check passes the user's zone and reads the
scheduled minute-of-day in-zone, so a dose far from UTC is graded
against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
include, so a future engine column reaches both surfaces.
The most common self-host login loop is a Secure session cookie issued
over plain HTTP: the browser drops it and bounces the user back to the
sign-in page with no error. Detect it at cookie-mint time
(detectInsecureCookieTransport) and emit a clear warn-log plus a
session_cookie_secure_transport_mismatch wide-event, pointing at
SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still
completes.
Oura fetched all 8 collections under one Promise.all: a single flaky
endpoint or one throwing mapper rejected the whole batch and imported
nothing (readiness, sleep, activity, spo2 all went dark together). Settle
each collection independently, import what succeeds, and carry the failed
ones as a per-collection ledger — mirroring Google Health / Fitbit. The
reactive 401-refresh is preserved: a shared expired token 401s every
collection, one refresh is done, and only the failed collections retry.
A partial failure records the failure and does not stamp success, so the
freshness surface stays honest and the next tick refetches.
- Dashboard metric tiles link to their /insights/<slug> detail page
(weight, blood pressure, pulse, glucose, mood, sleep, steps, VO2 max);
tiles with no dedicated sub-page stay un-linked rather than misroute.
- Daily-briefing key findings now route hrv, resting HR, active energy,
flights, distance, body temperature and recovery to their sub-pages —
the METRIC_HREF map had gone stale as those routes shipped.
- RecentWorkoutsTile adopts the canonical TileHeader (foreground icon +
title) instead of a muted hand-rolled row.
- Hero-strip header glyph moves off text-primary to the foreground tier.
The bundled compose constructs DATABASE_URL inside the app service from
POSTGRES_PASSWORD, so the getting-started 'edit CHANGE-ME' step was a
no-op and .env.example's literal POSTGRES_PASSWORD collided with the
quick-start's appended one. Comment both out in .env.example so the
append pattern yields a single key, and rewrite step 2 to state compose
derives the URL. Add a first-user note to close public registration on
an internet-exposed host and to save the password (no-SMTP CLI reset).
Refresh the stale onboarding step-2 source comment.
The 60s statement_timeout + pool ceiling applied to the web client in
db.ts were never applied to the worker clients. A pathological nightly
drain on a heavy tenant could run unbounded, pin a connection, and wedge
the shared worker pool. Route both worker clients (reminder handlers and
the Apple Health import worker) through the same getPoolMax() +
buildSessionOptions() helpers so every worker session is timeout-bounded
from its first query.
The upload route awaited an unguarded enqueueDocumentIndex after the
document was already committed. A transient boss.send rejection (a DB
hiccup) would then surface as a 500 for an upload that in fact stored the
file. Guard the send inside the enqueue (a failure annotates and no-ops,
matching the missing-boss path) and drop the await at the call site so the
response never blocks on or fails because of background indexing.
The PATCH detail route now reads the content-index provenance, so its route
test mocks documentContentIndex.findUnique; the searchable status spinner and
the Read-with-AI pulse pair with motion-reduce:animate-none per the coverage
guard.
Auto-index on upload means content search is silent-by-default, so the old
per-document index chore no longer reads as a task. Consolidate every AI
reading action into one inviting block on the detail sheet, led by a
prominent Read with AI action, and show each document's searchable status as
a pill that distinguishes an AI-read document from a locally-indexed one.
- New Read-with-AI block: prominent read/re-read action plus suggest and
session-only summary, gated on provider availability; the calm settings
pointer replaces the actions (never an error) when no provider is set up.
- Searchable status pill reflects the auto-index: Read by AI (provider read
the original, highlighted), Searchable (local), or a quiet not-yet state.
- Card searchable marker distinguishes an AI-read document with a Sparkles
glyph from a locally-indexed one; both carry an accessible label.
- Drop the manual index/re-index chore copy; keep the corpus baseline
affordance in the filter bar for older un-indexed documents.
Surface the content-search index's source (vision vs local extraction) on
the list + detail DTO so the vault UI can tell an AI-read document from a
locally-indexed one. Threads the source through both read paths and the
serialisers, regenerates the OpenAPI contract, and adds a narrowing helper
for the free-String column.
Index a document for content search automatically the moment it is stored,
instead of requiring a manual index action. A fire-and-forget per-document
job runs an AI-first decision tree: when a vision-capable provider is
configured and consented, transcribe the stored original (rich, handles
scanned PDFs and images), budget-reserved exactly like the interactive index
route; otherwise fall back to server-side text-layer extraction via pdf-parse
(no provider, no egress, milliseconds) so content search works at a baseline
even with no AI. Budget exhaustion, a provider that cannot read the file
(a text-layer PDF on a non-Anthropic account), and provider errors all fall
through to the free local path rather than leave a document unsearchable.
Both paths write the same blind, encrypted content index (AES-256-GCM text +
opaque HMAC token tags) — nothing readable at rest. The local path adds the
'local-pdf' / 'local-ocr' source values (free String column, no migration).
Server-side OCR for scanned images and scanned PDFs is left as a documented
seam. Pin pdf-parse + pdfjs-dist into the standalone trace so the wasm text
extractor ships in the runtime image.
Unit: the shared ShareLinkCreateForm mounts bare (Settings default) and
seeded (document flow) — a pre-attached document rides in as a chip with
the label pre-filled. e2e: the document detail Share action opens the
create flow with that document pre-attached and reaches the one-time QR
reveal; marked slow (two stacked sheets + a real create).
Extract the clinician share-link create flow (create form, document
picker mount, one-time QR + passphrase reveal) from the Settings sharing
section into a reusable ShareLinkCreateForm. Settings mounts it unchanged
alongside the active/revoked lists; the document detail sheet adds a
Share action beside Download that opens the same flow in a bottom sheet
with the current document pre-attached and its title pre-filled as the
label. The one-time QR reveal lands in that sheet, scannable on mobile.
No backend change: the shared POST /api/share-links handles the attached
document set as before.
Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
Add a persisted hero ring ORDER over the health-score anchor ring plus the
selected score rings. Defaults to the health-score ring on the leading edge;
the user can reorder the full set (health score included) from Settings →
Darstellung with the existing drag-handle + arrow-button pair.
The order rides the dashboard-layout blob (dashboardWidgetsJson) — no
migration. resolveHeroRingOrder reconciles a stored order against the current
ring set on every read/serialize, so a deselected ring drops and a
newly-selected ring appends; the hero orders only the rings it actually
renders.
Give each facet trigger a leading icon and show the text label from sm up
(and on phones only while the facet is active, truncated), so three facets
plus the search never overflow a 360-430px row — the bar stays one line
without a horizontal scroller. Drop the now-dead filter.groupLabel key and
point the vault e2e flows at the dropdown menu items instead of the removed
inline chips.
- Dashboard hero: pull the briefing closer to the ring row (md:gap-3 -> md:gap-2); mobile spacing unchanged.
- Each hero ring is now a focusable link to its natural detail: readiness/recovery/sleep -> their score-anatomy page, doses -> the medications surface, health score -> the Insights overview. Real anchors keep tap-vs-drag native on the mobile carousel.
- Mental-wellbeing: shorten the page title to the short noun form (matches the nav/module label) across all six locales.
- Instrument cards: move the required attribution/licence line off the space under the Start button to a discreet info control at the top-right of the header; a popover carries the verbatim citation. Start now stands alone.
The vault filter bar rendered the type/condition/year facets as a
horizontally-scrolling chip set beside the search field, which wrapped to a
second line on phones. Replace it with three compact dropdown facets — type
(multi-select), condition and year (single-select) — that share one
non-wrapping row with the flexing search and the clear control at every
width. The content-search hint keeps its own helper line below.