Extend the SSRF pin inventory to also assert the requirePublicHost pins
already present on the user/operator-supplied moodLog push/sync webhooks
and the ntfy sender, so the full set of overridable-host egress points
is CI-guarded and a future edit that drops one reds here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Prisma unique-constraint (P2002) narrowing predicate had been
hand-copied into five call sites. Fold them into a single isP2002 in
src/lib/prisma-errors.ts and import it everywhere (the two step-drain
sites keep their local isUniqueConstraintViolation name via an import
alias). Behaviour-identical; one place to chase a future P2002-semantics
change instead of five.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single shared forgetOne mutation meant forgetOne.isPending disabled
every per-fact forget button during one delete, so the whole list read
as frozen. Track the in-flight fact id via the mutation variables so
only the row being deleted disables and shows the spinner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The eight v1.10 additive HealthKit signals (cardio recovery, six-minute
walk, stair ascent/descent speed, breathing disturbances, wrist
temperature, falls, walking steadiness) are writable dashboard widget
ids so the native client's Home-pin PUT validates them, but the web
dashboard renders strip tiles via hardcoded per-id blocks and has no
render path for them. Left as-is, the web Settings -> Dashboard list
would offer eight toggles that do nothing on web.
Name them in a new IOS_PIN_ONLY_WIDGET_IDS set (writable-but-not-
web-rendered) and filter that set out of the rendered settings list, so
the toggles never appear on web while the ids stay accepted by the
widgets PUT enum for the native client. Add tests asserting the eight
remain writable (catalogue membership) yet are absent from the web list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The synchronous DAY rollup hook carries no pg-boss singleton, so two
measurement writes landing on the same (user, type, day) can interleave
their delete-then-insert and the losing createMany trips the per-source
unique index. The peer rewrote the identical source-collapsed partition
— the aggregate is a pure function of the same underlying rows — so the
rollup is already correct. Catch the P2002 in the single-chunk
transactional path, annotate it, and return rather than failing the
recompute. Any other Prisma error still bubbles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the unused `now` field from the coach `ExtractOpts` type; fact
extraction never read it (conversation-summary keeps its own `now`).
- Correct the no-ladder tiebreak docstring in measurement-read: the
collapse picks the alphabetically smallest source name, matching the
live-SQL `ORDER BY … source` tiebreak, not max count.
- Replace the hand-inlined canonical-source subquery in the summaries
rollup-path narrows query with `canonicalMeasurementsFrom(rank, "90 days")`
for live/rollup parity with its siblings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add eight widget ids (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) to the dashboard widget catalogue + default layout (hidden by default) so they can be pinned via the layout PUT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into both
readBestGranularityRollups calls. Completes the B3 fast-path threading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into both BP readers
instead of letting each re-query the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into the three
readRollupBuckets calls instead of letting each re-query the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Source-grep guard asserting the openai/anthropic/geo outbound sites keep
the unconditional DNS-rebinding pin and the local AI client keeps its
conditional LAN escape hatch — so a future edit can't silently re-open
the SSRF surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add requirePublicHost to the three outbound safeFetch sites whose host is
user- or operator-controlled and lacked the connect-time pin: the OpenAI
and Anthropic clients (BYO base-URL override) and the IP-geo lookup
(IP_GEO_LOOKUP_URL env). A base URL resolving to a private/metadata
address is now rejected. The LOCAL AI client intentionally stays
conditional (LAN escape hatch). Redirect-follow is already closed by the
safeFetch defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a 'What the Coach remembers' settings section: lists the user's
durable facts grouped by category with a learned-when stamp, forgets a
single fact or clears all (behind a confirm), and notes the rolling
conversation summary. Consumes the v1.11.1 CoachFact routes via the
query-key factory; gated on the Coach feature; localised in all six
locales. Closes the API-only gap from v1.11.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the three v1.11.0 follow-ups: cross-source vital de-duplication
(measurement rollups resolve overlapping sources to the priority-ladder
canonical reading across charts, summaries, insights, and the live
fallback), the Coach's rolling encrypted conversation summary, and
durable encrypted personal facts with a list/forget surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the two never-wired *_PROMPT_VERSION exports the dead-code gate
flagged, and consume the summary-length target as a real backstop that
caps the stored rolling summary so a misbehaving provider can't bloat
the encrypted column or the next prompt's history window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- conversation-summary: fault-isolate the per-message decrypt in the
fold loop so one undecryptable historic turn (e.g. mid key-rotation)
no longer permanently stalls a conversation's summary refresh.
- source collapse: unify the no-ladder tiebreak on alphabetically
smallest source across the JS reader and the all-time rollup SQL, so a
ranked vital whose day carries only off-ladder sources resolves to the
same canonical source on the warm and the live-SQL read paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the GDPR/trust surface for the durable Coach facts: GET lists the
caller's active facts decrypted, DELETE /[id] soft-deletes one, and a
bulk DELETE clears all — every verb apiHandler + requireAuth +
requireAssistantSurface("coach") + ownership-scoped, with an unknown or
cross-user id collapsing to an idempotent no-op that never leaks
existence. Registered in the OpenAPI contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildCoachMemoryBlock gains a third fault-isolated sub-source — the
top-N durable facts — folded under snapshot.memory so they ride the
existing lowest-priority registration and are shed first under the
char budget. Add an EN + DE system-prompt paragraph instructing the
Coach to use facts as the user's own stated, descriptive context —
never as a diagnosis, never invented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fetchConversationWithMessages now returns the decrypted rolling summary
(fail-closed → null), and buildHistoryWindow substitutes it for the dead
placeholder so a long conversation keeps memory of its elided turns.
Once a conversation grows past the turn cap the chat turn fires a
fire-and-forget coach-memory-refresh so the next long turn is fresh,
without adding a provider round-trip to the interactive reply.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the coach-memory-refresh pg-boss queue: a fire-and-forget enqueue
contract (coach-memory-shared) and a worker pipeline that runs the
conversation-summary refresh then the fact extraction for one long
conversation, each fault-isolated and budget-gated. Register the queue
in allQueues + a boss.work handler in the worker, with a source-grep
inventory guard so the queue can never ship unregistered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the two background generators behind Coach long-term memory, both
reusing the budget-aware provider chain (runStatusCompletion) with a
local fallback and encrypting their output at rest:
- conversation-summary: folds the turns elided past the history window
into a rolling natural-language summary, merging into any prior
summary; short-circuits cheaply until a conversation grows long.
- facts: extracts durable, descriptive personal facts (preferences,
stated conditions, goals, constraints, context), gated by a Zod
schema + de-dup + per-user cap; buildCoachFactsBlock ranks the top
facts for snapshot injection. Never diagnostic, never numeric.
Both are pure/injectable for unit testing and annotate counts only,
never content. Wiring into the queue, route, and snapshot follows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add CoachConversation.summaryEncrypted and CoachFact.factEncrypted to
the key-rotation registry, mirroring the CoachMessage.encryptedContent
Bytes round-trip, so an operator dropping a legacy key re-encrypts the
new Coach-memory columns instead of bricking them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the AES-256-GCM Bytes codec from persistence into a shared
bytes-codec module so the conversation summary and the new personal-fact
store reuse one ArrayBuffer-backed implementation. Add the CoachFact
table (migration 0116) for durable, user-scoped facts the Coach extracts
from conversations — encrypted fact text, plain category/confidence/
source for rank-without-decrypt, soft-delete for a reversible forget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the source-aware rollup read path: the slim summaries slice
(rollup + live), the comprehensive aggregator (both paths, including the
dailyByType correlation feed), and the /api/measurements route (rollup
read + the live date_trunc fallback) now resolve overlapping sources to
the source-priority ladder's canonical reading per (type, day) before
aggregating. A dual-source vital (e.g. WHOOP + Apple Watch resting heart
rate) surfaces one canonical series instead of a blend or a doubled
count; cumulative metrics keep summing per canonical source.
The collapse runs in SQL where the aggregate must stay a single
round-trip (buildSourceRankCase emits a per-user ladder-rank CASE;
canonicalMeasurementsFrom restricts a live read to canonical-source rows)
and in application code (collapseRollupRowsBySource) for the row readers,
both driven by the same resolved ladders so live and rollup agree.
Also widens the enum-literal SQL whitelist to accept digits so VO2_MAX
no longer throws on the typed write-hook recompute or the rank CASE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readRollupBuckets and the WEEK/MONTH/YEAR readers now select the source
column and run collapseRollupRowsBySource before composing, so every
rollup consumer (dashboard sparkline, fast-paths, derived metrics, range
deltas) sees one canonical row per bucket. loadUserSourcePriority loads
the ladder blob once; readBestGranularityRollups threads it through each
granularity probe so the per-floor walk never re-queries the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
collapseRollupRowsBySource resolves the per-source rollup rows minted by
the source-aware writer down to one row per bucket, using the user's
source-priority ladder (the same ordering the raw-row picker uses). A
metricKeyForType superset maps every overlapping non-cumulative vital
(RHR, HRV-SDNN, SpO2, body/skin temperature, respiratory rate, weight,
body fat, blood pressure, pulse, VO2max, recovery) plus the cumulative
keys onto a SourcePriorityMetricKey. Unlisted sources fall back to the
highest-count row so a bucket never doubles or goes dark.
Wired into the rollup readers in the following commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rollup rows were grouped by (type, day) only, so two sources reporting
the same standard vital for one day (e.g. WHOOP + Apple Watch resting
heart rate) were AVG-blended into a single row instead of resolving to
the source-priority ladder's canonical reading.
Re-grain the rollup table to (type, day, source): the writer now groups
by source and delete-then-inserts each affected partition (so a source
that stops reporting a bucket cannot strand a stale row), and the read
path will collapse overlapping sources to the canonical reading in a
follow-up commit. Cumulative metrics (steps, energy) keep summing per
source. Migration 0115 purges the legacy source-blind aggregates; the
boot backfill + read-time self-heal re-mint per-source rows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WHOOP integration card adds a third status pill on
/settings/integrations. Update the Pixel-5 layout spec to expect three
pills and assert the two connected ones (Withings + moodLog) order-
independently, since WHOOP stays a dormant BYO-keys card until an
operator adds credentials.
Add WHOOP as a synced provider (per-user BYO-keys OAuth2 + HMAC-signed
webhooks), the longitudinal coach (narrative + per-metric trajectory),
and the read-only FHIR R4 REST API plus the scoped, time-limited
clinician share-link record across README, the self-hosting and ops
runbooks, and a new WHOOP integration page. Note the two optional
WHOOP instance env vars, that migrations 0110-0114 are additive, and
the two known limitations (dual-source standard-vital dedup deferred;
coach durable-memory deferred).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rotation registry missed the per-user WHOOP credential columns and
the WhoopConnection token columns, so a key rotation would skip those
rows and leave them undecryptable once the operator drops the old key.
Add whoopClientIdEncrypted / whoopClientSecretEncrypted to the User
field list and a WhoopConnection accessToken / refreshToken pass.
Replace the incomplete tablist/tab markup on the week/month toggle with
plain buttons carrying aria-pressed, give them a 44px minimum tap target,
and add a focus-visible ring. The visual treatment is unchanged.
The clinician view rendered metric names through an English-only humaniser
while the rest of the page was translated, leaving a mixed-language summary for
non-English clinicians. Reuse the doctor-report type-label keys (already present
in every locale) for the per-type label, falling back to the humaniser only for
a type with no key.
The capabilities share descriptor exposed scopeable FHIR resourceTypes, but no
/api/fhir/* route honours a share token — those routes require an authenticated
fhir:read Bearer. The share serves the rendered record view only. Drop the
unrouted resourceTypes from the advertised share scope and add an explicit
fhirApi:false flag so an integrator knows the share to FHIR face is not live.
When WHOOP and another source both supply a standard vital (resting heart
rate, blood oxygen, body temperature, respiratory rate, sleep stages), both
values can appear until a future update settles on a single preferred source.
Show this as a calm muted note on the WHOOP card so the known limitation reads
as documented behaviour rather than a bug. New whoopOverlapNote key across all
six locales.
Fold the deterministic short-horizon forecasting engine into the Coach
prompt as a compact, lowest-signal snapshot block. For each in-scope
metric the block carries direction + slope + the projected horizon end
with its widening prediction band, the last observed value, and the
fit's R² / server-computed confidence — read straight from the existing
projection engine, never recomputed. A metric below the fit/history/
staleness floor is omitted; the block is dropped entirely when none
qualify, and it sheds first under prompt-budget pressure.
Add a conditional-projection ground rule so the Coach narrates a
trajectory only when the block is present and only for the metric it
covers: direction and range, always conditional, never a certainty, a
risk score, a diagnosis, or a dated event; absent block means no
projection. The rule lands in the safety-contract matrix across all six
locales and inline in the EN + DE system prompts, with an overclaim
probe asserting a projection is never upgraded. Bump PROMPT_VERSION.
Assemble a zero-LLM longitudinal memory block from artefacts already
persisted: the latest period-narrative headline and a per-metric
prior-vs-current band recall. The Coach can now reference what was true
last period instead of re-deriving a cold snapshot every turn.
The block is machine-derived (labels plus numbers only), so it is
unencrypted and adds no model cost. Each sub-source is fault-isolated —
a failing narrative read or context build drops that sub-block and never
sinks the turn. It registers against the lowest-signal cluster so the
budget degrader sheds it first, before any clinical data.
Prove a WHOOP run and the same Apple-Health run inside the 5-minute clustering
window collapse to the APPLE_HEALTH canonical row (richer GPS + HR record),
and a lone WHOOP run survives when no richer source logged the session. The
measurement-side WHOOP/Apple/Withings oracle already lives in
source-priority-whoop-applehealth.test.ts; this closes the workout axis.
Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the
$everything operation and honoured search params) to fhir/rest.ts; the
metadata CapabilityStatement and the share-link resource-type enum now
derive from it so the advertised set can never drift from what is routed.
Extend GET /api/meta/capabilities with the FHIR REST descriptor
(restBaseUrl, readScope, resourceTypes, operations, searchParams) and a
clinician-share descriptor (supported, maxDays, resourceTypes, scopeable
sections) — every list sourced from its canonical registry, guarded by
the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to
match.
Fill the clinician test-matrix gap: assert the clinician view never
requests a KVNR / identifier opt-in from the aggregator (default-OFF by
construction) and resolves a rolling window without reaching data older
than the frozen start.
The body-content secret guard matched hlk_ / hlr_ / sk- but not the
new hls_ clinician share-link prefix, so a response echoing a freshly
minted share token could be persisted to the idempotency cache. Add
hls_ to SECRET_PATTERN alongside the existing token prefixes.
Add a short-horizon OLS projection over the DAY-native per-day mean
series, extending the existing trendSlope. The fit, residual standard
error and prediction interval are computed at DAY granularity only — the
rollup invariant forbids composing a slope/sd from WEEK/MONTH buckets.
The projection is gated on fit quality (R squared), in-window history and
staleness: below any floor the engine returns insufficient rather than a
weak line, so noise is never extrapolated. The prediction-interval band
widens with the horizon distance from the data centre, so the fan
visibly communicates uncertainty.
Register TRAJECTORY in the derived registry and dispatch (additive,
iOS-forward-compatible; the capabilities endpoint and drift-guard pick it
up automatically). Surface it on the weight detail page as a forecast
band beneath the chart, labelled a projection with the cited method and
a conditional, range-only headline — never a prediction or a dated
event. i18n across all six locales.
Connect mints the state nonce into the WhoopOAuthState ledger and redirects
to the WHOOP authorize page; callback verifies the nonce (atomic
delete-first consume, four distinguished reason tags), exchanges the code,
fetches the WHOOP profile for the user id, persists the encrypted
WhoopConnection, and enqueues the self-converging history backfill.
The webhook handler rate-limits per source first, verifies the
WHOOP_WEBHOOK_SECRET path segment, then verifies the HMAC-SHA256 body
signature over the raw bytes (timestamp + body, timingSafeEqual, stale
timestamp reject) before any work. Valid *.updated enqueues the matching
per-resource sync; *.deleted soft-deletes (workouts hard-delete, no
soft-delete column). Unknown users return 200 to stop retries.
CSP gates api.prod.whoop.com to the WHOOP settings surface + /api/whoop/*;
the webhook path joins the public-path allowlist and the path-secret redact
registry. Env wiring adds WHOOP_WEBHOOK_SECRET + WHOOP_REDIRECT_URI to the
compose whitelist and .env.production.example.
Add a Sharing section under Settings (between API and Export) where the owner
creates a read-only clinician share link, sees their active and inactive
links, and revokes one. Create wires to the C4 lifecycle API: it sends the
label, a rolling history window, an expiry (capped at 90 days, validated
before the round trip), an optional scoped FHIR-API toggle, and the FHIR
resource types. The raw token is revealed exactly once on create as a
copy-able `/c/<token>` URL; the list only ever carries the server's safe
projection (no token, since only its hash is stored). Revoke confirms through
an alert dialog and invalidates the list.
RSC page mounts a client island; reads unwrap the envelope and key off the
centralised query factory. New `settings.sharing` / `settings.sections.sharing`
strings land in all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register the `/c/` prefix in `PATH_SECRET_PATHS` so the raw `hls_` clinician
share token, which rides as the trailing path segment, is rewritten to
[REDACTED] in `http.path` / `http.route` before any Wide Event or GlitchTip
report leaves the process — the same posture as the Withings webhook path
secret. The resolve path never logs, throws, or annotates the raw token.
Defend the share surface at the edge irrespective of what the RSC emits: the
proxy now sets `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`,
and `Referrer-Policy: no-referrer` on `/c/` responses, so no downstream proxy
caches a scoped record, no crawler indexes the token, and the token-bearing
URL is not leaked in an outbound `Referer`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a calm "your week / month in review" summary to the Insights
overview, generated from the period-narrative context (deltas, personal
bands, FDR-controlled associations, coincident days).
- New insight_narratives table (migration 0113): one durable row per
(user, period, locale); generated prose AES-256-GCM at rest following
the coach-message precedent; labels-only provenance stays plaintext.
- generatePeriodNarrative feeds the context into the provider chain with
a tight, descriptive-never-causal prompt; an insufficient context
yields no narrative, a missing provider no-ops, neither fabricates.
- Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated
per user; the read-only GET enqueues a single-user warm on a cold read.
- Read route is stale-while-revalidate and never blocks on the provider;
the card renders plain text (no markdown), a single provenance
disclosure, and a fixed footprint (no CLS). i18n in all six locales.
Build the WHOOP sync layer on the W2 client, mirroring the Withings
queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since
the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's
after-the-fact re-scoring, maps via the W2 mappers, and upserts
Measurements with source=WHOOP keyed on (userId,type,source,externalId)
so a re-post overwrites in place. Workouts upsert into Workout with
per-workout strain + HR zones in metadata and kJ->kcal energy.
Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior
access and refresh token on every refresh). A self-converging boot
backfill enqueues one full-history sync per un-backfilled connection and
stamps backfillCompletedAt; the discovery query drops completed accounts,
idempotent across reboots.
Every new queue is registered in the worker's allQueues + cron schedule
(recovery/sleep/workout poll catch-all, cycle poll-only, backfill,
oauth-state sweep). IntegrationStatus gains the whoop key + label.
Tests: rotating-refresh persistence, idempotent measurement upsert,
incremental-window overlap, backfill self-convergence, and a
queue-registration inventory guard.
A standalone read-only RSC that resolves the path token through the share-token
resolver and renders an owner-scoped clinical summary: provenance header,
clinical vitals and labs, medications with adherence, and a fenced muted card
for the descriptive wellness scores carrying the not-a-clinical-assessment,
not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI,
no session, no markdown — every value renders as escaped text. force-dynamic and
noindex; the page returns a flat 404 for any null resolve.
The route joins the proxy public-path allowlist and the AuthShell standalone
list so it reaches the page bare without a sign-in redirect. New clinicianView
strings land in all six locales.
resolveShareToken hashes a raw hls_ token, looks up the ClinicianShareLink,
enforces not-revoked and not-expired, and returns a ShareContext carrying only
the owner userId plus the frozen scope. It is not an auth primitive: it never
reads a session or sets a cookie, so a share token can authenticate only the
share surface. Unknown / revoked / expired / malformed tokens resolve to null
for a blunt 404. Access counters bump fire-and-forget on a hit.
A token presented on a normal authed route is rejected by construction —
requireAuth reads only the cookie session and Authorization: Bearer (against
ApiToken), never the share header, and an hls_ token has no ApiToken row. The
invariant test locks that boundary.
GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,
MedicationAdministration} and $everything, returning type:"searchset"
Bundles (total + self/next links, offset paging via _count clamped to 200)
as application/fhir+json, with an OperationOutcome on error. The per-resource
emitters are reused from src/lib/fhir/resources.ts so the document export and
the REST face share one coding source and can never drift. Auth is a narrow
fhir:read scope via requireAuth (cookie sessions also pass); userId is
narrowed from auth. Read-only — no write handlers. Rate-limited per user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Authenticated owner routes to create, list, and revoke a ClinicianShareLink.
Create mints an hls_ token (192-bit), stores only its HMAC hash, and returns
the raw token exactly once; the scope columns (window, sections, FHIR resource
types, API toggle) are frozen write-once. expiresAt is required and capped at
SHARE_LINK_MAX_DAYS (90). Revoke sets revokedAt and seals a cross-user id as
404. userId is narrowed from auth; rate-limited; strict Zod rejects unknown
keys. This is the owner lifecycle only — the public resolver/view is separate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the WHOOP integration data layer on copy-of-Withings rails:
- client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize
URL / code exchange / rotating-refresh, the four v2 collection fetchers
(recovery, sleep, cycle, workout) with next_token pagination, single
body-measurement + profile reads, and the field->Measurement mappers
(RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/
SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*,
DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to
DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never
share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies.
- response-classifier.ts: HTTP-status-driven success/transient/
reauth_required/persistent verdicts + WhoopApiError, mirroring the
Withings reason-tag classifier.
- credentials.ts: per-user BYO-key resolution, decrypting the client
id/secret from the User columns.
- oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants.
- mapping.md: field->Measurement reference, kept in sync with the mappers.
Unit tests cover the classifier, pagination, every fetcher, the mappers
(ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and
credential resolution against mocked WHOOP JSON. No routes / OAuth flow /
sync workers / migration yet.
Assemble the structured, provenance-carrying data the period narrative
narrates, without any LLM call. buildPeriodNarrativeContext reads one
bounded window covering the current and prior period of equal length and
delegates to a pure core that produces metric deltas, derived-band
transitions, FDR-surviving correlation drivers, and coincident-deviation
flags as label+number+source beats.
Reuse the rollup-backed daily series, the median ± k·MAD personal band,
and the existing Benjamini-Hochberg correlation engine, so the
descriptive-never-causal contract carries through verbatim — a driver
never enters without clearing the FDR target, and each keeps its
conservative interpretation string unchanged. Gate on data availability:
too little history returns an insufficient shape, never a fabricated
story. Pure core is unit-tested over seeded fixtures.
Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.
The ledger holds a durable negative cache: an auth-class failure
(401/403) benches that provider for a cooldown instead of re-burning the
dead round-trip on every generation; a sustained hard failure backs off
briefly. The local model is never benched — it stays the guaranteed
floor of the chain. Every ledger interaction fails open, so a DB error
falls back to today's chain walk exactly.
When the user's primary provider fails auth-class, the Coach emits a
distinct credential_expired frame that deep-links to reconnect rather
than a generic "try again later".
Adds the provider_health table (migration 0112) and the
errorCredentialExpired copy across all locales.
Add the WHOOP integration data layer (migration 0111): seven new
MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN,
SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED,
ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection
and WhoopOAuthState models, and the per-user encrypted BYO-key columns
on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and
RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native
value never shares a bucket with a derived proxy.
Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
Lift the Observation / MedicationStatement / MedicationAdministration /
Patient / Coverage builders — with their LOINC/ATC/SNOMED/UCUM codings and
the survey-category wellness split — out of build-bundle.ts into a shared
src/lib/fhir/resources.ts. buildFhirDocumentBundle now composes the emitters
so the upcoming FHIR REST search routes reuse the exact same coding logic
instead of a second copy of the mapping.
Pure refactor: the document-bundle output stays byte-identical (the existing
FHIR + export tests pass unchanged). The shared coding constants and option
types re-export from build-bundle so existing import paths keep working.
Owner-minted, scoped, time-limited, revocable read view of the health
record for a clinician holding the raw hls_ token. The token is stored
only as an HMAC-SHA256 hash; the scope (range start, sections, resource
types) is frozen at creation and never widened. expiresAt is mandatory
(capped at SHARE_LINK_MAX_DAYS, default 90); only revokedAt, lastAccessAt
and accessCount mutate after mint. Cascades on user delete.
Schema + migration only; no behaviour yet.
The Enright & Sherrill 1998 predicted-distance equation was established on a
40-80 reference cohort; the metric accepts adult ages 18-120, so the
prediction extrapolates outside that range. State it in the user-facing
caveat (all locales) and the norms doc so a younger or older user reads
their percent-of-predicted with that honesty.
Carry the per-day STRAIN anchor (personal vs population) from the
strain_trimp_cache row through to the STRAIN WellnessScoreValue read, and
pick the framing line by it on the score-anatomy surface: relative to your
own typical effort once anchored personally, a general reference during
cold start. The method copy was describing both regimes generically and
could not tell the user which one produced this score. Additive read field
— iOS non-breaking.
The dynamic loader skeleton reserved 120px while the resolved card's
insufficient (~168px) and fired (~192px) states ran taller, shifting the hero
and everything below it down 48–72px on resolve. Pin a min-h-48 on the card
shell sized to the tallest realistic state, and match both the chunk-loader
skeleton and the in-component CardSkeleton to that footprint (including the
44px provenance header row) so there is zero shift across loading → any state.
Announce the fired state to screen readers via a polite role=status (not the
assertive role=alert), and mark the decorative loader skeleton aria-hidden.
The estimated 6-minute-walk band + the three any-user HealthKit baseline
bands (wrist temperature, stair ascent/descent speed) were registered,
provenance-mapped and engine-tested but rendered on no surface. Add a
"Mobility & body" section to the vitals dashboard: the 6MWT passthrough
re-frame (distance + trend always, percent-of-predicted framing only with
the Enright demographics) and a shared baseline-band tile for the three bands.
Every tile is data-availability-gated like the existing ones — absent with no
readings, CoverageMeter while provisional, value + framing + provenance when
ready — and the section heading hides when none resolve. All four tokens ride
the single batched derived request. Derive the sparkline gradient id via
useId() to avoid duplicate-label collisions.
The personal-relative-anchor explanation (own recent training-day load vs a
general reference while building history) was EN-only; de/es/fr/it/pl carried
the stale v1.10.0 method+description. Port both strings to all five so the
two-mode honesty distinction is visible regardless of locale. Also correct the
stale strain-score header comment: STRAIN is not excluded from the doctor PDF —
it rides a segregated, disclaimed Wellness-summary section.
The sleep midpoint was minutes-of-day in UTC, so a non-UTC sleeper's
consistency and timing sub-scores drifted with the offset (a 03:00-local
midpoint read as a different clock time than a UTC user's). Resolve the user's
stored zone in computeSleepScore and express the midpoint against it via the
existing tz helpers; reconstructNights takes an explicit tz (UTC default) so
the pure path stays test-pinnable.
The readiness + coincident-deviation latest-day-mean reads cap the row scan
on a dense intra-day day. Lift the bare 50 into a named MAX_LATEST_DAY_ROWS
constant and document the dense-intraday-retention reasoning at both call
sites so the bound is intentional rather than a loose literal.
The age/sex reference tables are coarse decade / paediatric brackets, so a
fractional age read a hard step at a bracket edge (39.9 -> 30s band, 40.0 ->
40s band). Anchor each band at its bracket centre and linearly interpolate a
fractional age between the two adjacent centres; below the youngest centre /
above the oldest centre hold the nearest cited band flat (clamp, never
extrapolate) so the band stays within the span of the two cited brackets and
the standard's provenance stays accurate.
The Derived<T> tile (SparklineDeltaTile) renders a trailing series, but the
engines never filled it, so the inline sparkline was always empty. Each engine
now carries a capped trailing series sourced from the rows it already reads:
- baseline-band metrics (incl. wrist-temperature / stair ascent+descent) ship
the per-day mean series the band is built from;
- HRV balance reuses the day-mean read it already does for the recent average;
- the wellness scores (recovery / stress / strain) ship their window scores;
- the six-minute-walk band ships its reading series;
- BMI reads a bounded recent-weight set and derives a BMI series at the fixed
profile height.
All series cap at the last 30 points. The vitals dashboard wires the series
into the baseline, HRV and BMI tiles; the tile already renders >= 2 points and
collapses the sparkline row otherwise.
The recovery / stress / strain nightly finders used distinct + take with no
order, so when more than the cap of users qualified the selected set was
arbitrary across runs. Switch to a grouped query ordered by each user's
newest input first with userId as the stable tiebreak, so the cap takes the
most-recently-active users reproducibly. The strain finder interleaves its
workout and active-energy lists so the merged cap favours recency across both
sources.
Map day-total Banister TRIMP to 0-100 against a personal reference -- the
EWMA-smoothed 75th percentile of the user's own training-day TRIMP over a
42-day chronic window -- instead of the fixed population anchor that pinned a
deconditioned or chronic-condition user near 0 regardless of their effort.
Below a 7-training-day cold-start floor the engine falls back to the
population anchor, recorded as anchor: population with lower confidence; at
or above it the personal anchor takes over. Always writes a score row -- no
new null gate. The anchor is derived from the TRIMP input, never the 0-100
output, so there is no circular feedback; rest-day zeros stay out of the
training-day distribution.
Add a server-internal per-(user, day) strain_trimp_cache (day-total TRIMP +
running EWMA reference + anchor) so the chronic window reads cheap cached
values instead of re-integrating 42 days of HR series each night. The cache
is not a Measurement -- it stays out of every user-facing surface, the
doctor PDF, and the FHIR bundle by construction. No backfill: the nightly
idempotent recompute populates the cache forward and self-heals, falling
back to the population anchor until enough personal history accrues.
Keep the Banister/Morton/Tanaka citations and the doctor-PDF exclusion; the
honesty copy now states the scale is relative to the user's typical effort,
with a general reference during cold start.
Register four new derived metrics for the additive HealthKit signals:
- WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE,
STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing
the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES
allowlist. Wrist temperature is a personal-deviation band only (no illness
or cycle inference); stair speeds are personal trend bands (no population
cutoff, geometry-confounded).
- SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated
distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002
test standard), as percent-of-predicted with a green/yellow/red band;
band null when age/height/weight/sex are incomplete.
Fall count and the breathing-disturbance index stay trend-only by design,
documented in the registry: fall count is a zero-inflated discrete safety
event, the breathing index is a regulated screening signal where a derived
band would imply a diagnosis.
Note wrist temperature as a deferred correlation outcome channel held
pending a privacy review (cycle-phase inference risk), mirroring the
medication-compliance omission note.
Add provenance + standards entries, method/caveat copy across all six
locales, and tests for the engine routing, the Enright equation, and the
percent-of-predicted band.
Promote the COINCIDENT_DEVIATION flag from a fire-only tile buried in the
below-the-fold vitals grid to an always-mounted headline card at the top of
the Insights overview. The card renders four calm states off the single
existing derived payload — building-baselines, all-clear, watch, fired — with
no new engine math, route, or schema change.
Keep it calm and clinically safe: at most an amber band, never red, no score,
no chart, no push. Soften a fired flag to the watch tone when the backing
history is thin. The fired/watch states carry the load-bearing "possible
factors, never a cause / not a diagnosis" framing and reuse ProvenanceExplainer
+ CoverageMeter + the band tokens verbatim.
Remove the now-redundant tile, its batch token, and the hasRenderableVital
branch from the vitals grid so the signal is not shown twice.
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
Resolve the canonical daily row deterministically: prefer the row already
carrying the target stats: externalId before falling back to the row on the
canonical local-noon instant, with a stable orderBy tiebreaker. Adopting a
sibling and stamping the target externalId onto it could otherwise collide on
the (userId, type, source, externalId) index with the real canonical row.
Re-anchor the adopted row to local-noon only when that slot is free, so an
externalId row at a tz/DST-shifted instant cannot collide on the measured_at
composite during adopt.
Guard the create against a concurrent insert on the same canonical composite:
catch P2002 outside the transaction (a caught error inside leaves the tx in
the aborted state) and retry the fold once, which then resolves and adopts the
winning row instead of creating a duplicate.
Add a real-Postgres case covering a row already on the canonical instant under
the target stats: externalId plus a fresh out-of-window batch: the fold adopts
in place, no P2002, one canonical row with the correctly folded mean.
- workouts/batch + safeJson: the body cap is a DoS ceiling, not a bound
the worst-case payload always fits under; an extreme GPS-heavy backlog
can exceed it and falls back to smaller batches client-side. Note that
request.text() still buffers the raw body, so the cap bounds the parse
cost, not the initial read.
- .dockerignore: keep LICENSE / LICENSE.md in the image — AGPL-3.0
conveys with the software.
- reminder-worker: the dense walk flag is an operator kill-switch now
that the fold is reworked, not a pending-rework gate.
Two assertions depended on the live wall-clock: the card suppresses the
upcoming-injection line while now sits inside a schedule window, so a CI
run during the 08:00-20:00 window rendered the pill instead of the
Next/Recommended-next line. Pin to 07:00 Berlin on a non-scheduled day.
The show-all-readings link now carries the originating metric page as a
sanitised from param, so a weight to show-all drill-in returns to weight
rather than the Insights overview. The param is constrained to an internal
/insights/<slug> path so a crafted value cannot redirect off-site.
The composite-score detail page (/insights/scores/[metric]) gains a leading
back-link to the Insights overview, matching the other drill-down surfaces.
Align the medication detail + history back-links onto the canonical ghost
recipe (-ml-2 w-fit, mr-1 size-4 icon) used by the values and workout-detail
pages, replacing the divergent -ml-2 gap-1 variant.
Mount MetricRangeControls on the measurement-series bespoke pages (weight,
blood-pressure, pulse, bmi, sleep) so the range selector no longer vanishes
when navigating from a HealthKit metric. Mood, workouts, and medications are
event-driven rather than MeasurementType series, so they document the
omission inline.
Apply the canonical part set where the data supports it: blood-pressure and
sleep gain the diversity nudge + show-all-values foot link. Blood pressure
omits the single-series stat strip (it is sys+dia) and bmi omits the strip /
nudge / show-all entry (it is derived from WEIGHT), each with an inline note.
Lift the time-range pills + period-over-period delta block out of
HealthKitMetricPage into a standalone MetricRangeControls component that
owns the range pref, the range read, and the direction-sentiment lookup.
The HealthKit scaffold now consumes the shared piece so the bespoke metric
pages can render the identical control.
The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
The connection-test route mapped a provider 5xx to a 502, which a reverse
proxy rewrites to its own HTML error page; the browser's res.json() then
threw "Unexpected token '<'". Every provider-call failure now returns
HTTP 200 with a secret-free { ok:false, providerType, reason }. Reasons
are categorised: credential/auth (401/403, or a 5xx whose body signals an
invalidated/expired session), rate-limit (429), provider server error
(other 5xx) and network/timeout.
The empty-override "test my saved config" path now resolves through the
same provider chain generation uses, instead of the legacy codex->admin
probe that could test a different provider than the one generation runs.
The chain runner already advances past an auth-class failure to the next
enabled provider; add explicit coverage for that on the raw generation
path (401 -> next succeeds, fallback count incremented).
Expose the server's live id vocabularies (derived-metric ids,
vitals-baseline types, insights layout tile-ids, metric-status ids,
the HealthKit ingest mapping, computed-score types, write allowlist) and
the FHIR coding constants, plus the running contract version, behind
requireAuth (cookie or Bearer, not admin).
Every list is sourced from its canonical server constant / registry, so
the endpoint cannot drift from what the routes actually accept and emit —
retiring the doc-vs-server enum-drift class for the native client. Export
the FHIR ATC/SNOMED URIs and a shared german-ATC default-locale set so the
export route and the discovery surface share one source.
Register the route in the OpenAPI table; drift-guard tests pin
derivedMetricIds to the registry and writeAllowlist to the constant.
Render a skeleton row matching the resolved tile height while the derived
batch loads so the grid reserves its final footprint with no layout shift.
Hide the section heading until there is content (or a skeleton placeholder)
under it, removing the stranded-heading layout shift on empty data. Surface
an inline error message plus a Retry that refetches the shared batch query
when the batch fails, instead of the whole surface silently vanishing. Mark
the tiles container aria-busy while loading and aria-live polite, matching
the insights a11y pattern.
The intake-history taken badge and the admin reminders status glyphs used
bare Tailwind -400/-500 shades chosen for the dark theme. On the white
Alucard light card those land well below WCAG AA (green-400 1.74:1,
red-400 2.77:1, yellow-400 1.53:1). Repoint to the success / destructive /
warning theme tokens (light values 6.1:1 / 5.0:1 / 5.8:1 on white) and pin
the original shades behind dark: so the dark rendering stays identical.
Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch
and export routes that lacked one. The cap measures the raw text
before JSON.parse, so an over-limit payload returns a clean 413
without buffering onto the heap.
- workouts/batch: reuse the existing 5 MB ceiling so chunked uploads
with no Content-Length are bounded too.
- measurements/batch: 4 MB — an order of magnitude above a 500-entry
batch's few-hundred-KB worst case.
- export/health-record: 64 KB — the selection payload is small and
bounded.
Each cap is a DoS ceiling sized well above any legitimate payload,
never a tight bound on a real batch.
The v1.10.0.2 kill-switch defaulted the drain OFF after the P2002 fold
collision. With the fold reworked to coexist with the canonical daily row
and integration-tested against real rows, flip the default back ON
(DENSE_INTRADAY_RETENTION_ENABLED=true unless explicitly set to false). The
boot-discovery still defers past the startup storm via the boot-delay so it
never contends for the connection pool on a data-heavy tenant; the nightly
03:50 walk and the per-user handler run again.
The dense intra-day retention fold minted its daily-mean stats: row with
an externalId-keyed upsert. On real data a row already occupies the
canonical local-noon instant — a sibling daily stats: row or a per-sample
HRV/PULSE reading that fell on noon — so the upsert lookup missed (the
existing row carries a different externalId) and the INSERT then violated
the (user_id, type, measured_at, source, sleep_stage) unique index with
P2002. The mocked unit tests could not surface it.
Resolve the canonical row by the measured_at composite the collision is on
(sleep_stage IS NULL for these continuous types, matched via the
NULLS NOT DISTINCT index) and adopt it in place — refresh the mean, stamp
the canonical stats: externalId, un-tombstone — or create it when absent.
Exclude the adopted row id from the per-sample soft-delete so a noon-instant
sample becomes the daily row instead of being tombstoned.
Add a testcontainer integration test that seeds the colliding shape (a
pre-existing canonical-instant row plus out-of-window per-sample rows) and
pins no-P2002, coexistence, the noon-sample case, idempotency, and the
retention bound. Update the unit coverage for the adopt-in-place path.
Extend the symmetry suite to assert both cards render the byte-identical
next/last slot wrapper (same reserved min-height, spacing, muted colour,
next-then-last order, no em-dash placeholder, no brighter foreground
token) and a constant-height bottom-pinned body across oral / injection
/ GLP-1 / PRN / one-shot, including the compliance-skeleton path. Update
the dose-accent assertions onto text-dose-accent and the GLP-1 rotation
test onto the folded-into-line nudge. Clock stays pinned.
Route both the generic and GLP-1 cards through the shared next/last
slot, ending the divergence that made two meds of different types read
differently in the same grid row:
- the GLP-1 card showed last-then-next; both now show next-then-last;
- its next line painted text-foreground/85; both now use the muted token;
- its inner spacing was space-y-1; both now use space-y-3.5;
- it always rendered the next line and printed a literal em-dash when
there was no next injection; both now gate the line on an actual next
and drop the placeholder.
Make CardContent a flex column and pin the action row with mt-auto.
Combined with the reserved next/last slot and the constant-height
compliance skeleton, every card body is now equal height, so the pin
aligns the take/skip buttons across a grid row without opening the void
that an unequal-height pin produced before.
Fold the GLP-1 injection-site rotation nudge into the upcoming-injection
line instead of a separate bordered block, so the GLP-1 card keeps the
same row inventory and height as the generic card.
Repoint both cards' dose accent onto text-dose-accent, drop the GLP-1
compliance query's active-only enabled guard so both cards make the same
calls, and share the weekday-label helper.
The medication cards stamped the upcoming schedule dose with Tailwind
stock text-purple-400, which reads ~3.1:1 on the white Alucard light
card and fails WCAG AA for body text. Add a theme-aware --dose-accent
token: the exact text-purple-400 oklch in dark mode (so the dark card
stays pixel-identical) and a darker AA-safe purple on the light card,
mirroring how the semantic feedback tokens land each tone above AA in
both themes. The cards switch to text-dose-accent in the follow-up
commit.
Extract the medication-card middle slot into a shared
<MedicationNextLastSlot> so the generic and GLP-1 cards render an
identical next/last region — same order (next then last), same
text-muted-foreground colour, same space-y-3.5 spacing, same reserved
min-height, and the same gating (the caller passes a next line only when
one should show; the part never prints a placeholder). A shared
useWeekdayLabel helper collapses the two weekday i18n key families onto
the abbreviated form so the same weekday no longer renders 'Monday' on
one card and 'Mon' on the other.
Add a MedicationComplianceSkeleton that reserves the compliance block's
footprint while the per-card query is in flight or returns null, so a
card without compliance is no longer ~5rem shorter than its grid-row
sibling.
The reminder worker failed to start in the production bundle with
`ReferenceError: Cannot access 'l' before initialization`, taking every
nightly pg-boss job down with it (score jobs + dense-intraday retention).
Root cause is a value-level import cycle between `consolidation-base.ts`
and `drain-per-sample-cumulative.ts`: each imported values from the other.
The bundler merges the tightly-cyclic pair into one module body and hoists
`DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of
the constant it reads, so the eager read hits a temporal dead zone. It only
surfaced in the production build because dev/test run the modules unbundled.
Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow`
shape, and the timezone day-math helpers — into a new dependency-free module
`consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle
at its root. Re-export the moved names from their former homes so every
existing import site resolves unchanged.
The semantic-token swap means the trend arrow/delta now emit
text-success/text-warning (pixel-identical in dark, AA-safe in light), and
the batched derived route joins the insightStatus allowlist alongside the
single route. Update the trend-card sentiment-colour assertions and the
coach-route gate inventory accordingly.
Only Sleep and Readiness reached a provenance surface; the other nine
shipped a number with the cited standard buried in a code comment. Give
every derived metric a path to its inputs + method + cited standard:
- Recovery, Stress and Strain get a ScoreAnatomyView detail page and a
tappable ring (they were read-only dead-ends). Stress surfaces its
'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain
cites the TRIMP load model; Recovery the HRV-guided recovery literature.
- The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age)
gain an info explainer affordance carrying the method + standard, since
they render as tiles with no detail page.
- The coincident-deviation flag gains a dashboard tile (shown only when it
fired) with the same explainer (median ± k·MAD basis).
- A single standards map (src/components/insights/derived/standards.ts)
pins each metric's method copy key, standard name and a real linkable URL
(DOI / WHO / Wikipedia) so the citation a metric exposes stays one source.
Add the per-metric method/caveat/description copy across all six locales.
Map storage units to display symbols so a baseline tile reads '36.6 °C',
not '36.6 celsius'. Show the band word under the strip rings so a value
reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline
placeholder when no trailing series exists rather than reserving dead
space, and give the tile a series + provenance slot for when one does.
The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.
Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
The entire insights.rhythmEvents namespace — section title, intro, the
load-error, and the medical-adjacent device-detection disclaimer — shipped
the English string verbatim in es/fr/it/pl. The locale-integrity guard only
checks key shape + placeholders, so the copy-paste passed CI while rendering
English on a Spanish/French/Italian/Polish device-notifications card,
disclaimer included. Translate the four keys into real localised copy.
The band primitives (score ring, coverage dots, contributor bars,
provenance chips + standard link, sparkline tint, trend arrow/delta)
painted straight from the raw `--dracula-*` variables, which carry no
`:root.light` override. On the white card the Alucard light theme uses,
the green/orange/yellow cleared roughly 1.1-1.5:1 — under AA — and the
`bg-dracula-yellow` confidence dot read near-invisible. The file comment
claimed they 'track automatically'; they did not.
Point BAND_NUMBER/PROGRESS/BORDER/VAR, the coverage-meter dot tint, the
provenance cyan chips, the sparkline stroke, and sentimentColorClass at
the semantic `--success`/`--warning`/`--destructive`/`--info` tokens
instead. Those alias the same Dracula tones in dark mode (pixel-identical
there, the primary target) but carry the darker AA-safe values under
`:root.light`. Clamp the score-ring provisional caption to one line so a
longer localised string cannot wrap over the ring arc.
WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it
gated the single-entry POST, but the schema only refined out COMPUTED —
WITHINGS and IMPORT stayed client-writable, so a client could attribute a row
to a source the server owns (the Withings webhook / CSV importer) and pollute
the per-source canonical picker. Wire createMeasurementSchema.source to a Zod
enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum
subsumes the old COMPUTED refine. Regenerate the OpenAPI contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The readiness and coincident-deviation composites passed historyDays =
windowDays (a constant) into deriveCoverage, pinning historyFraction to 1 so
a 7-day and a 30-day blend reported identical confidence. Pass the real
distinct-history-days backing the composite (the depth of the
best-supported present contributor) so confidence tracks actual depth.
The readiness provenance source was derived only from RHR/HRV/respiratory, so
a blend backed solely by sleep + mood could mislabel as DAY. Fold the present
sleep + mood provenance in so the source chip matches what actually backed
the headline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sleep-score consistency + timing sub-scores measured the midpoint as a
linear minutes-of-day difference, so a sleeper whose midpoint straddles
midnight (and any night across a DST shift) read a spurious ~24-hour spread
that inflated the SD and timing distance. Measure the midpoint on the
circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint
and a circular distance for the SD and timing offset.
Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to
100, masking a data problem. Handle the overlap explicitly: a small overshoot
caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross
overshoot drops the sub-score because the in-bed denominator is not usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
computeHrvBalance read recentAvg = baseline.value.center, which is the band
midpoint by construction and therefore always inside [low, high] — so the
"low" and "unbalanced" arms were dead and the card could only ever read
"balanced". Compute recentAvg from the last <= 7 DAY means (the recent
trend) and place that against the longer-window personal band, so a
suppressed recent trend flags "low" and an elevated one flags "unbalanced".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55
Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours
old: Strain missed the just-completed day's workouts and Stress saw a
near-empty intra-day SDNN set and falsely gated as insufficient. Score the
day that just ended (now - 1 day) across all three engines and their
measuredAt / externalId day stamps.
Resolve the previous-day key in one place by extracting the shared score-row
helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt /
scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch
tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into
the baseline engine (the three byte-identical profile loaders + the partial
strain variant now route through it). The derived route should adopt
loadBaselineProfile too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a derived wellness score (readiness or recovery) crosses a notability gate
— outside the green band AND confidence at least medium — the insights
generator now appends a SYSTEM CONTEXT block so the briefing can narrate the
shift observationally. Mirrors the GLP-1 plateau detector's append mechanism:
reads the one derived contract, returns null (the common case) at zero token
cost, and instructs the model to frame the signal as an observed pattern, never
a diagnosis or a dose recommendation. Add the readiness/recovery sourceMetric
enum values plus their briefing href + icon + trend-chart mappings.
Surface the server-derived nightly scores (recovery / stress / strain) in the
health-record export without polluting the clinical layer. The aggregator now
emits a wellnessScores summary (latest / avg / min / max / count per score over
the window) built from the already-fetched COMPUTED rows. The PDF renders them
in a clearly-labelled "Wellness summary" section, separate from the clinical
vitals table, with a "descriptive, not a clinical assessment" disclaimer. The
FHIR bundle emits one Observation per score under the survey category with a
text-only concept and the same disclaimer as an Annotation note, so a viewer
never mistakes a band for a diagnosis.
Add a "derived" block to the Coach snapshot: one tiny object per high-signal
derived metric (readiness, recovery, sleep, stress, strain, HRV balance,
cardio-fitness band, vascular-age delta) carrying value + band + confidence +
historyDays — never the raw series. A fully-populated block is ~250 tokens,
comfortably inside the snapshot budget. Insufficient metrics are omitted so the
model never sees no-data noise; each metric's compute is fault-isolated so a
transient failure on one never sinks the turn. Gated on the underlying signals
staying in scope. The system prompt now documents the block and frames the
bands as descriptive composites, not clinical findings.
Add a wellness-scores section to the vitals dashboard: a row of score rings for
Readiness, Recovery, Sleep, Stress, and Strain. Every ring is
data-availability-gated — a score with no underlying data simply does not
render, so the strip never shows an apologetic empty ring. Readiness and Sleep
are tappable and route to their existing score-anatomy detail pages; the
persisted nightly scores render read-only. Every ring reads the one
/api/insights/derived route through the shared useDerivedMetric hook.
Register RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE as derived metrics that
read the nightly-persisted COMPUTED Measurement rows — a passthrough, never a
recompute. The dispatcher routes the three ids to a thin reader that returns
the latest score, a trailing-window trend, and a direction-aware band (stress
and strain invert, so a high stress reads red). This lets every surface — the
dashboard, the Coach snapshot, the doctor report — consume the scores through
the one Derived<T> route + hook rather than each growing its own read path.
Regenerate the OpenAPI enum, which picks up the three ids from the registry.
Two parallel loaders for the generic derived-metric route had grown — a
components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a
3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix
with different tuple shapes, which silently poisons the TanStack cache between
surfaces. Collapse to the single components-local hook (auth-gated, 8 s client
ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is
always present so the metric prefix can no longer carry two shapes.
Wire the two nightly score passes and the dense intra-day retention drain
into the worker. The stress (04:50) and strain (04:55) crons each get a
queue, a schedule, and a single-flight handler; the dense-retention drain
gets a per-user backfill queue with boot discovery (like mean-consolidation)
and folds its steady-state nightly walk onto the existing drain-cumulative
tick, after the daily-mean fold.
A queue not registered in allQueues silently never drains, so a source-grep
guard pins every registration site for all three jobs.
Derive a daily cardio-load proxy from the per-workout heart-rate series
plus active energy. Each workout's HR series scores via Banister's
gender-weighted exponential TRIMP over the fractional heart-rate reserve
(Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals
map to a 0-100 saturating scale. Days with no usable HR series but a
non-trivial active-energy total fall back to an energy-only proxy, with the
path recorded in the result.
Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated
when neither a usable HR series nor active energy is present.
Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP);
Tanaka, Monahan & Seals 2001 (HRmax).
Apple Watch has no electrodermal sensor, so there is no direct stress
measurement to read — what HealthKit does give is discrete SDNN samples
through the day. Derive an honest HRV-through-day PROXY: a suppressed,
below-baseline intra-day SDNN is a recognised marker of higher sympathetic
load. The provenance and label say "HRV-derived proxy" and never imply a
galvanic/EDA reading.
The engine reuses the shared vitals-baseline math (the same SDNN baseline
the wellness index reads), maps the day's below-baseline suppression to a
0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a
client can never POST it. It gates and writes nothing below the minimum
intra-day sample floor or without a usable baseline.
Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012
(neurovisceral integration). Idempotent per user per day via the nightly job.
Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep
their intra-day shape long enough for the stress engine to read them, but
the raw per-sample volume must stay bounded. Add a retention tier that
keeps the last 14 days of raw HRV/HR samples and folds everything older to
one daily-mean row via the proven mean-consolidation drain (same idempotent
stats:<HK>:<day> fold, soft-delete, and rollup recompute).
The two dense-tier types are deliberately EXEMPT from the destructive
daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would
collapse the intra-day shape and soft-delete the raw rows. A regression test
pins both exclusions so a future edit that adds either type to the drain
allowlist fails loudly.
Migration 0108 adds only the partial index that keeps the retention drain's
discovery and per-user scan cheap; the retention bound lives in code so
tuning it never needs a migration.
Replace the generic page description with the self-hosted positioning —
Withings + Apple Health sync, transparent derived wellness metrics, and
AI Insights you own — so the search-result snippet matches the README
and landing copy. OpenGraph and Twitter card descriptions are unchanged.
Add an honest "Versus WHOOP / Oura / Fitbit ecosystems" subsection that
positions HealthLog on self-hosting, data ownership, multi-provider
aggregation, and the blood-pressure / glucose / medication context the
wrist bands don't hold — without claiming to be or match a wearable
device. Refresh the comparison table with BP+glucose, medication
compliance, derived metrics, and FHIR rows.
Fold the v1.10 derived-metrics tier into the status line, Key Features,
and roadmap: fitness-age, vascular-age delta, HRV balance, sleep score,
readiness and the stored recovery score, and the coincident-deviation
early-strain flag — each computed from the user's own measurements,
citing its inputs, degrading to "not enough data" rather than
fabricating a value. Stress and strain scores stay out of the copy: the
types are reserved but no engine computes them in v1.10.
Add a recovery-score engine that turns a user's already-stored nightly
signals into a stored 0-100 score. It reuses the readiness blend — the
resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation
from the personal baseline, with the null-redistribution the health
score already uses — so the stored series and the live readiness surface
can never drift. The lineage is the recovery-marker literature (Plews
2013, Buchheit 2014): a higher score reads as more recovered. It is a
descriptive wellness proxy, not a clinical or training-recovery
assessment, and it is only stored when enough components are present —
below that floor no row is written rather than a headline from one
signal.
A nightly job computes each eligible user's score and upserts it as a
COMPUTED RECOVERY_SCORE row, idempotent per user per day on the
externalId day key, so a re-fired tick overwrites in place instead of
duplicating. The score is never computed on a page visit. The queue is
registered in the worker's queue inventory, scheduled, and wired to a
handler; a grep guard pins all four points so it cannot silently never
run.
Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).
COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.
All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.
Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
Raise the per-export administration ceiling from 1000 to 5000 (~3.5 years
at four doses a day) and resolve it from FHIR_MAX_MEDICATION_ADMINISTRATIONS,
bounded 1..50000 with a fallback to 5000 on an unset or out-of-range value.
The windowed-then-capped newest-first behaviour and the honest-truncation
narrative are unchanged; this only raises the ceiling so it bites far less
often. Add the var to the compose environment whitelist and the production
env example so it reaches the container and check-env stays green.
The builder already accepts a germanAtc option that appends the BfArM ATC
URI after the WHO entry. Thread it from the health-record export route: an
explicit germanAtc flag on the selection payload wins, otherwise it derives
from a German-region locale. The WHO coding stays coding[0], byte-identical
and locale-independent, so a WHO-only consumer is unaffected.
Add the optional germanAtc field to the strict export selection schema and
regenerate the OpenAPI contract.
Emit a SNOMED CT coding alongside the existing .text anchor on the
administration dosage. Route maps ORAL to 26643006 (Oral route) and
INJECTION to 34206005 (Subcutaneous route, the form HealthLog injections
take). Site keys on the eight InjectionSite enum values, collapsing to the
three gross body-region concepts (abdomen 818983003, thigh 68367000, upper
arm 40983000); laterality and the abdominal quadrant stay verbatim in the
.text anchor rather than inventing lateralised concepts.
Referencing concept ids in a FHIR instance is not redistributing the
licensed SNOMED distribution, so this is safe to ship. The .text anchor is
unchanged, so a consumer without a SNOMED binding is unaffected.
Reconcile the dose fixture to the real uppercase enum value the live
builder emits (the previous lowercased value never matched production).
Add mapping tests for the five event classes (verdict resolution,
severity grading, the value-pinned-to-1 contract, unknown-codepoint
fallback, and that a continuous measurement never gets a classification),
the awareness-card gating + device-verdict framing + disclaimer presence,
and the events route narrowing + empty-state contract. Extend the enum,
chart-token and Coach-gate drift guards for the new event types.
Add a timeline of the health notifications the user's device produced on
its own, reachable from the Insights overview. The surface reflects the
device's own decision: each row reads "Your device flagged …" and a
permanent disclaimer states this is not a medical assessment by
HealthLog and that HealthLog does not diagnose any condition.
The surface is data-availability-gated — it un-mounts entirely when the
user has no such events, so an account that never triggered one never
sees an empty or alarming card. Copy renders as plain React text
children; no markdown library is involved.
Accept the optional HKCategoryValue codepoint on each batch entry and
persist the resolved rhythm_classification on the stored row. The wire
field is additive and ignored for non-event identifiers, so every
existing caller is byte-for-byte unchanged. Regenerate the OpenAPI
contract.
Add a discrete EVENT row class to the Measurement model for the
device-flagged health notifications a wearable produces on its own —
irregular-rhythm, high/low heart rate, walking-steadiness and
breathing-disturbance alerts.
Following the sleep-stage / glucose-context nullable-context precedent,
this is an additive nullable column on the existing table, not a new
storage paradigm. Five new MeasurementType values carry the event class
and a new rhythm_classification enum column carries the device's own
verdict / severity (migration 0105, with a CHECK that pins the column to
the event types). An event row stores value 1 and the device verdict;
it never participates in trend, rollup, chart or personal-record
analytics.
For the irregular-rhythm class, HealthLog ingests only the
classification RESULT the device's own certified on-device algorithm
already produced. It never ingests a raw ECG waveform, never
re-classifies, and never derives a verdict of its own.
The Apple Health batch ingest maps the five category identifiers, with
the device's HKCategoryValue codepoint resolved to the stored
classification; an unknown codepoint degrades to a fallback verdict
rather than dropping the event.
Wire seven previously-deferred HealthKit quantity identifiers through
the full pipeline — enum, mapping, plausibility range, category, list-UI
metadata, FHIR coding, and the generic metric-status assessment:
CARDIO_RECOVERY heartRateRecoveryOneMinute bpm
WRIST_TEMPERATURE appleSleepingWristTemperature celsius
FALL_COUNT numberOfTimesFallen count
SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance m
STAIR_ASCENT_SPEED stairAscentSpeed m/s
STAIR_DESCENT_SPEED stairDescentSpeed m/s
BREATHING_DISTURBANCES appleSleepingBreathingDisturbances count
Each value rides raw on the wire (identity conversion). Fall count is
cumulative (daily SUM); the stair speeds drain to a daily mean; the rest
store as latest per-workout / per-night spot readings. Wrist temperature
and breathing disturbances are flagged privacy-sensitive. The category
sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they
are events, not quantities.
Each new metric gains an Insights sub-page on the shared scaffold with the
generic assessment card, localised across all six locales. Cardio fitness
(VO2 max) — ingested since v1.4.23 but assessment-less — gains its own
/insights/cardio-fitness page so it picks up the assessment instead of
riding as a bare chart-row on the pulse page.
Normal-range anchors are cited where defensible (six-minute walk 400-700 m,
ATS guideline + population reference); the rest defer to the user's own
baseline rather than fabricate a band.
Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only).
Extend POST /api/workouts/batch with an optional per-workout `samples`
array — a route-independent heart-rate series so indoor workouts still
yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?,
power?, cadence? }`; the array is capped at 30 000 points
(MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at
1 Hz while bounding the largest new workout-side write stream the same
way the 20 000-point route cap bounds the geometry.
Persistence rides the existing route-attachment path: the freshly
written workout id is resolved once (by composite key, or the manual
tuple), then both the route geometry and the HR series attach by FK
with skipDuplicates so a re-submitted batch stays idempotent. A workout
may carry both a GPS route and a canonical HR series, or just one.
GET /api/workouts/{id} returns the series alongside the route. OpenAPI
regenerated.
`samples` keeps first-write-wins/overwrite parity with the workout row
it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an
existing workout never duplicates or mutates its series.
Indoor workouts had no home for their per-sample heart-rate series: it
rode on WorkoutRoute.sampleTimestamps, which only exists when a workout
carries GPS geometry. A treadmill or trainer session therefore lost the
signal the training-strain reader needs.
Add a WorkoutSamples child (workout_samples), 1:1 with the workout like
WorkoutRoute, holding a JSONB `[{ t, hr?, speedMs?, power?, cadence? }]`
array plus a denormalised sample_count. JSONB keeps the self-hosted
Postgres extension-free, mirroring the route table's decision. Migration
0107.
Also retire the stale cross-source workout-dedup marker on the Workout
model: that work landed as the write-time dedupeWorkoutBatch() picker
plus the read-time pickCanonicalWorkoutRows() picker, both resolving the
user's source-priority ladder. The comment now documents the two layers
instead of tracking them as open.
Resolve the overlap where the vitals tier and the composite tier both extend
the derived-metric registry, dispatch, query-key factory, i18n bundles and the
OpenAPI contract: every derived metric (vitals baseline, fitness/vascular age,
HRV balance, BMI, sleep score, readiness, coincident-deviation) is now wired and
implemented, the dispatch test asserts the full set, and the locale bundles carry
both the vitals and the score-anatomy/composite strings.
The v1.9.0 equal-height change set the card content to a flex column and
pinned the action buttons to the bottom with mt-auto. On a shorter card next
to a taller one in the grid, that opened an empty gap between the content and
the buttons. The cards already stretch to a shared height via the grid, so the
buttons can sit directly after the content: drop the flex column and the
mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the
dose lines still align across the grid; only the gap is gone.
Build the composite tier of the derived-metrics layer on the shared seam:
- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
consistency, timing and composition sub-scores over the per-stage sleep
rows, reweighting around any sub-score the data cannot support (a legacy
asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
deviation-from-baseline components; returns insufficient below two present
components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
the latest day and fires when two or more coincide, listing the
contributing vitals as possible factors, never a cause or a diagnosis.
Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.
Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.
Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.
Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
Build the Apple-Health-Highlights grid of personal-vital tiles on the
Insights overview, backed by four new pure derived-metric compute
functions over the established seam:
- cardio-fitness band ("Fitness Age" frame) — VO2max placed against the
FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never
re-derived.
- vascular-age delta — Withings vascular-age vs chronological age, a
relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical
claim.
- HRV (SDNN) balance — reuses the typical-range engine for the band and
places the recent average as balanced / above / below; SDNN never
relabelled RMSSD; cadence-gated.
- BMI from weight + height — exact WHO classification, the fallback when
no device BMI exists.
Each metric carries coverage + confidence + provenance and cites its
standard through the provenance explainer. The dashboard renders one
SparklineDeltaTile per available vital: an absent vital does not render,
a still-building one shows the honest coverage meter rather than a
headline from too little history. Tiles read the generic
/api/insights/derived route through a TanStack Query hook keyed off the
centralised factory.
Regenerates the OpenAPI contract for the two new metric ids and adds the
vitals copy across all six locales.
The recurring medication stub schedules an 08:00-09:00 window and the card
derives its header from the real clock. When CI ran inside that window the card
showed the in-window 'take now' pill and correctly suppressed the 'Nächste
Einnahme:' next-due line, so the chip assertion failed depending on wall-clock
time. Pin the clock to a fixed afternoon before rendering the list, putting the
morning window in the past so the next-due line always renders.
The medication-list next-due line is computed client-side from the schedule
after hydration, so on a loaded CI runner it can appear a beat after the
medication name. The chip assertion used the default 5s timeout while the name
assertion above it uses 10s; align them so the check isn't racing hydration.
The v1.9.0 equal-height change set the card content to a flex column and
pinned the action buttons to the bottom with mt-auto. On a shorter card next
to a taller one in the grid, that opened an empty gap between the content and
the buttons. The cards already stretch to a shared height via the grid, so the
buttons can sit directly after the content: drop the flex column and the
mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the
dose lines still align across the grid; only the gap is gone.
Build the four reusable presentational components the v1.10 vitals and
score-anatomy surfaces share: a radial score ring, a coverage meter, a
sparkline delta tile, and a provenance explainer. Each extends the
existing dracula band + trend-sentiment vocabulary rather than a new
colour system, and ships its populated, provisional/empty, and stale
states up front so a sparse-data surface never goes blank.
- score-ring: centred tabular-nums number on a Recharts RadialBarChart,
band-coloured, size prop (sm tile / md / lg anatomy), count-up + sweep
on mount via a 0 KB requestAnimationFrame hook; role=img aria-label
restates the number so the ring is never colour-only.
- coverage-meter: CSS-only filled/hollow dots driven by the Derived<T>
coverage + confidence facets, with an accessible breakdown tooltip
(present vs required inputs, history days, missing list).
- sparkline-delta-tile: extends the trend-card grammar with an inline
Recharts area sparkline, a signed period delta, and an age/normal-range
framing slot.
- provenance-explainer: collapsible Popover (desktop) / bottom-sheet
(mobile) rendering inputs, plain-language method, and an optional cited
standard as React text children — no markdown library.
All four read their data off the Derived<T> union as a type-only import
so the client bundle stays free of the server graph. Recharts only, no
new runtime dependency. New i18n keys propagated across all six locales.
Thread the reference-range enabler (derived/norms.ts) into the existing
generic assessment path. When lookupNormalRange resolves an age/sex
adjusted band for the metric + profile, the snapshot uses it in place of
the coarse flat registry anchor and tags it normalRangeSource:
age-sex-adjusted; otherwise the flat anchor is kept unchanged. Strictly
additive — a metric with no sharper norm, or a profile missing
demographics, behaves exactly as before.
Generic route mirroring the v1.8.7.1 metric-status precedent:
GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<vital>].
apiHandler + Zod safeParse on the query, closed registry enum (unknown
id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed
from the session (never a query field). The profile is read once and
passed into the pure compute function. Returns the flat Derived<T> union
in the standard { data, error, meta } envelope so the native client
decodes one stable shape and combines values across metrics.
Register the query + response schemas (DerivedMetricQuery,
DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the
path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml.
Add the route to the insights gate inventory (insightStatus-gated, no
Coach prose).
Route tests cover the 200 flat-envelope shape, session-narrowed userId,
422 on an unknown / missing metric, 401 unauthenticated, and the
insufficient flattening (null value + confidence, populated reason).
Introduce src/lib/insights/derived/ — the shared, compute-once layer for
derived wellness metrics. Core pieces:
- types.ts: the Derived<T> discriminated union (ok carries value +
coverage + confidence + provenance; insufficient carries coverage +
provenance + reason). Client-safe — pure types, no server imports.
- coverage.ts: deriveCoverage() + the buildOk/buildInsufficient terminal
constructors. Confidence blends the history fraction and the
input-presence fraction so it drops monotonically as inputs go missing.
- registry.ts: the closed DERIVED_METRIC_ID enum + per-metric metadata
(inputs, archetype, min-history / min-inputs floors, implemented flag).
- norms.ts: age/sex reference-range tables (VO2max FRIEND registry,
resting-HR + respiratory-rate by age) behind lookupNormalRange().
Age + sex only — no ancestry/region ranges.
- baseline.ts: the flagship engine — computeVitalsBaseline() returns the
personal typical-range band (median +/- k*MAD) for one vital. Center
reads the composable DAY rollup mean; the spread is DAY-native only
(the sd/slope/r2-don't-compose invariant), with a per-type bounded
live-SQL fallback on a coverage miss.
- dispatch.ts: maps a DERIVED_METRIC_ID to its compute function; stubbed
metrics return an insufficient with reason not_implemented.
- index.ts: the append-only public barrel.
Unit tests cover the coverage model, the Derived<T> ok/insufficient
branching, the norms lookup, the baseline ok / insufficient /
coverage-miss-fallback paths + the composed-vs-raw-DAY band parity, and
the dispatch stub guard.
GET /api/medications/{id}/compliance was absent from the OpenAPI document, so
a client generating types from the contract could conflate it with the separate
cadence endpoint (a different shape). Register the route with its exact response
— compliance7/compliance30 summaries, the per-day dailyCompliance grid, and the
two-row complianceDisplay block — documenting that compliance30.totalExpected is
the 30-day denominator and that a per-day glyph is drawn only where due is true.
Handler unchanged; this closes the contract gap.
Opening the Insights overview fired a once-per-session background warm that
forced the provider to regenerate the comprehensive briefing and every category
and metric assessment at once — a multi-minute burst that contended with
foreground requests and made the app feel unresponsive until it finished. The
nightly pass keeps the caches warm and the per-metric status GETs revalidate
gently on their own, so the warm-on-mount is redundant. Remove it; the manual
'prepare assessments' button remains the explicit on-demand re-warm.
Insights gain selectable time ranges with period-over-period deltas, and the
mood page adds time-of-day, stability and weight/BP correlation reads. Medications
can carry ATC + RxNorm codes that flow into the health-record export alongside a
MedicationAdministration per acted dose. The connection pool is sized for real
concurrency to remove the under-load stalls, and the medication advanced-settings
page is rebuilt. The standalone /targets page is retired in favour of inline editing.
The v1.9.0 time-range work imported ANALYTICS_RANGES as a value into the
time-range pills and the insights layout-prefs hook from range-delta.ts.
That module also pulls the rollup readers and the Postgres client, so the
value import dragged pg (dns/fs/net/tls) into the browser bundle and broke
the production build with eight "Module not found" errors.
Move the client-safe pieces — the range list, its type, the per-range
window length, and the period-over-period result shapes — into a new
range-shared module that imports nothing server-side. The insights client
components import from there; range-delta.ts re-exports the same names so
the server route and the OpenAPI registry keep their import paths. The
window-composition logic and the rollup/db imports stay server-side.
No runtime behaviour change — a pure module-boundary split.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75124e76c1e987d8a022fb39d891049340199b38)
The reserved-slot comment claimed the compliance bars and action row
align "row-for-row across the grid" with a generic sibling. That
overstates it: the GLP-1-only rotation-hint block sits between the
reserved slot and the compliance bars, so the bars are pushed down by
its height and do not share a Y with a generic card's bars. What the
layout actually guarantees is equal card height and a shared bottom
action baseline (`h-full` + `mt-auto`); only the header, the reserved
slot, and the pinned action row align across a mixed-variant row.
Reword the comment to claim exactly that.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7320c8b1b9f704d22eaba10be42a560594a5ca11)
The administration set was windowed only to the report period (up to 730
days). A chronic medication dosed several times a day across that window,
multiplied across several medications, can still materialise thousands of
MedicationAdministration resources into a single Bundle — bounded by the
window but unbounded by dose frequency.
Cap the set at the most-recent 1000 acted intakes overall. The omitted
rows are the oldest in the window, so recent adherence is preserved
intact. When the cap trims rows the aggregator records the original and
included counts and the FHIR document narrative discloses the
truncation, so the export is honest about the omitted history rather
than silently dropping it. Cover the cap in the report aggregator and
the narrative note in the bundle builder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6ce7df2bf6efb5e2c9d948d8f189d95d1289758)
The RxCUI field validates against `^\d+$`, which is unbounded — a caller
could submit an arbitrarily long digit string. Add a `.max(20)` ceiling
as defence-in-depth so the column write stays tight; real RxCUIs are at
most a handful of digits. `atcCode` needs no max — its regex already
fixes the length. Regenerate the OpenAPI contract for the new
`maxLength` on both the create and update bodies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8b97d35bc375ef3b2e70f938c0d6729a5fa6bccb)
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.
Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
- Range pills: make the radiogroup a single tab stop with roving tabindex and
Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern.
Previously every pill was its own tab stop and the arrow keys were dead.
- Range delta caption: render a neutral dash + "no change" for a perfectly
stable metric instead of a down-arrow with "−0%". Adds the `noChange`
string across all six locales.
- Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the
body-scrolled shells (login / register / onboarding / legal) via a
`data-scroll` attribute the shell sets, so the height-locked authenticated
branch no longer reserves a second, never-painted gutter on classic
scrollbar platforms.
- Mood tiles: a lone in-target / stability tile now spans full width instead
of leaving a half-width orphan beside it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.
Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.
Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.
Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
The authenticated shell scrolls inside its own `<main>` (the body is height-
locked with `flex h-dvh`), and that `<main>` centres its content with
`mx-auto`. A tall section paints the vertical scrollbar and narrows the
content box; a short one hides it and widens the box, so the whole column —
including the admin/settings sidebar — recentres a few px sideways on every
view switch. The admin "Insights Quality" ↔ "Integrations" toggle was the
visible case.
Reserve the gutter with `scrollbar-gutter: stable` on the `<main>` scroll
viewport, and on `html` for the body-scrolled login / onboarding / standalone
pages. The content box width is now constant whether or not the bar is
painted, so the left nav no longer shifts when a scrollbar appears. The fix is
global: every signed-in route renders inside the same `<main>`, so the same
shift on settings, insights, and dashboard navigation is fixed at once. The
height-locked authenticated body never scrolls the document, so no double
gutter results.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c1459e5ee42b7f7acbfa933a9eb7d1f5a1c6890a)
The settings section reverts to the single-line "Notifications" /
"Benachrichtigungen"; update the shell title assertions to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.
Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.
Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.
Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
The notification-channels section heading and the left-nav label both
render `settings.sections.notifications.title`, whose German value
("Benachrichtigungs-Kanäle") is a long compound that wraps to two lines
in the nav rail. Shorten the displayed value to the plain plural across
all six locales (de "Benachrichtigungen"; en "Notifications"; es
"Notificaciones"; fr "Notifications"; it "Notifiche"; pl
"Powiadomienia"). Key id is unchanged; the breadcrumb on the
notification-center page keeps its own wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit af437f48b09b9f99b6a80709ddacd565c10132ab)
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.
Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.
The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
scripts/assert-deploy.ts polls /api/version on prod + demo and asserts the
served data.version matches an expected value, printing each target's
buildSha and exiting non-zero on a mismatch or after a bounded retry loop.
This catches the BuildKit layer-cache pitfall where the deploy reports
clean but /api/version still answers the prior release. Run via
`pnpm dlx tsx scripts/assert-deploy.ts <version>` — not bundled into the
standalone image. Document it in the deploy runbook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a86a9151ef670846c4b33a1c068da40324393308)
POST /api/insights/pregenerate warms the assessment cards — the comprehensive
insight plus the specialised status cards — all of which the read-only status
GETs gate on the insightStatus surface. It was gating on coach, so a user with
assessments enabled but the Coach disabled got a 403 and could not warm. Move
the gate to insightStatus to match the surface it actually serves, add it to
the route-gate inventory allowlist, and cover the coach-disabled-still-warms
path with a route test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b)
The generic metric-status route set the OpenAPI pattern for the assessment
cards; retrofit the six specialised routes onto it. Add registry entries
for /api/insights/{blood-pressure,pulse,weight,bmi,mood,medication-compliance}-status
mirroring the metric-status envelope (hasProvider/text/cached/updatedAt plus
the preparing + revalidating flags). Medication-compliance carries its richer
summary + per-medication note array as its own schema. Regenerate the YAML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 91887a13b30e8978e91f615604163331bdc54950)
Align the server's `Coverage` emission with the iOS exporter: a
Coverage is now emitted whenever ANY payor signal is present, including
a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the
`subscriberId` with no contained payor Organization; the insurer-known
case is unchanged. A bundle with no payor signal at all still omits
Coverage entirely.
Also add a top-level `Composition.text` narrative (reusing the same
escaped summary the section narrative carries) so a strict
US-Core-style validator sees the document-level narrative it expects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43)
Emit one `MedicationAdministration` per intake the user actually
actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`),
`not-done` for an explicit skip (`effectiveDateTime = scheduledFor`).
Pending / missed slots and soft-deleted tombstones are never exported,
so the record only asserts administrations that happened.
Each administration reuses the statement's ATC/RxNorm + text concept,
so it is self-describing without a cross-resource reference. A `dosage`
is emitted only when a structured dose Quantity is available (resolved
from the dose-change history in effect at the administration instant) —
a dosage carrying only `.text` would violate the R4 dose-or-rate
invariant. Route + injection site ride along as text-only concepts. The
administrations are referenced from the existing Medications section, so
the document's two-section graph is preserved.
`FhirMedicationAdministration` is new; `FhirDosage` gains `dose`,
`route`, and `site`. The source rows come from the same aggregator the
PDF consumes, keeping FHIR and PDF aligned by construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
The health-record export's `MedicationStatement.medicationCodeableConcept`
gains additive `coding[]` entries when the medication carries a stored
code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary
(`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name
stays the `.text` anchor and the concept collapses to exactly the
prior text-only shape when no code is present — the export never
machine-guesses a code, only emits what the user stored.
The aggregator now carries `atcCode` / `rxNormCode` on `medications[]`
and assembles the acted-intake source rows
(`medicationAdministrations`) the administration builder consumes, so
the FHIR export keeps describing the same numbers as the PDF by
construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811)
Add two nullable, additive columns to `Medication` (`atc_code`,
`rxnorm_code`) so a user or clinician can assert a drug-classification
code that the health-record FHIR export emits on the medication concept.
Codes are taken verbatim, never machine-guessed: a wrong code is a
clinical-safety problem, an absent one is merely a (conformant)
text-only concept.
- Migration 0103 — additive, nullable, no backfill. Pre-existing rows
read NULL, which the exporter already treats as "no code".
- Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`;
422 on a malformed value. Wired field-by-field through the create +
update routes (no mass assignment); `null` clears a code on update.
- Surface both codes in the medication advanced-settings sheet's Data
group with a self-saving input, and echo them on the GET/POST/PUT
response shape. OpenAPI regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165)
The external-API row was the tallest thing in the advanced-settings sheet:
a full-width endpoint URL block plus an always-expanded 4-9 line request
snippet, on screen for every user whether or not they touch the API.
Collapse the multi-language request examples behind a chevron disclosure,
closed by default — the language picker and the live-interpolated bearer
token now only render once the user opts in, keeping the token-shaped
payload out of the default-visible DOM. De-emphasise the raw endpoint URL
to a muted, single-line truncated `<code>`: the copy button already carries
the full value, so the long absolute URL was redundant noise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6310ed49f4523e3210b26c05f2c934748967af88)
The advanced-settings sheet crammed the external ingest endpoint into the
Data group next to CSV import/export — two unrelated audiences sharing one
frame. Pull `ApiTokensRow` into a dedicated "Externe API" group so the
developer-facing surface reads as a distinct, lowest-frequency block. New
group order: Reminders -> Lifecycle -> Data -> Externe API -> Danger.
The new group reuses the existing `SettingsGroup` bordered-card chrome, so
it stays visually consistent with its siblings. Adds the `externalApi`
group label across all six locales and updates the composition test to pin
the five-block order plus the API row's new home.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f9f3a1277b5aad1710815511ddf7f6b210a652b2)
Render the new mood analyses on the insights surface:
- a four-bar time-of-day chart (morning / afternoon / evening / night),
shown only when the server marks the pattern reliable
- a stability tile beside the in-target tile, with the descriptive band
- the time-of-day takeaway in the narrative feed (new Clock icon, daypart
label resolved from the bundle)
Also wire the mood x weight and mood x systolic-blood-pressure scatter
cards. Their Pearson coefficients and points were already computed and
returned by the endpoint but never rendered; they now sit alongside the
sleep / activity / pulse cards, each gated on the same minimum-pairs
threshold so a thin pairing renders an empty state rather than a noisy r.
New i18n keys land EN-first and propagate across de / es / fr / it / pl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
Extend the mood-aggregate engine with two pure helpers over the rows it
already fetches:
- computeTimeOfDayAverages buckets every entry into morning / afternoon /
evening / night in the entry's own timezone (legacy null-tz rows fall
back to UTC). A spread guard keeps the once-a-day logger — whose entries
cluster in a single bucket — from producing a misleading daypart claim:
the pattern is only marked reliable once at least two buckets each clear
the per-bucket sample floor.
- computeMoodStability maps the population standard deviation of the daily
means onto a 0-100 score (higher = steadier) with a descriptive,
non-judgemental band. Sparse loggers below the minimum-days floor get
null rather than a meaningless number.
Both feed the MoodAggregates object that drives the visible mood page and
the LLM snapshot, so the prose and the charts agree. A new time-of-day
narrative takeaway joins the ranked feed, gated on the same reliability
flag plus the existing effect-magnitude bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82)
The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.
Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.
New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.
The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
A constantly-syncing client drips measurement batches every few minutes.
Every batch deleted the dirtied per-metric assessment cache rows and
re-enqueued a regeneration. The per-(user,scope,locale) enqueue singleton
is only 120 s, so it could not coalesce across batches that arrive minutes
apart, and each delete dropped the row a fresh enqueue would otherwise have
found warm — so a metric card regenerated several times an hour and its
assessment felt regenerated on every visit.
Before evicting and re-enqueuing, check whether the scope's cached
assessment was already (re)generated within a 30 min debounce window. A
scope that is fresh is left intact and not re-enqueued; a genuinely stale
or missing scope still refreshes immediately, so correctness holds. A
timeout stub never counts as fresh, so a scope that recently stalled still
retries. One indexed read per user answers it for every candidate scope at
once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 024baea664490ef0494add78c87492f01e61c6b9)
The on-demand force-warm ran the comprehensive insight first and only ran
the per-status (7) and generic-metric (~30) assessment passes when that
step returned generated or cached. A slow or stalled comprehensive
generation therefore aborted the entire warm — observed in production as a
102 s run ending with assessments_warmed: 0 — leaving every card cold and
the user back on the lazy 30-60 s on-first-click path.
Give the comprehensive step its own bounded budget and treat a timeout,
error, or skip as non-fatal: the per-status and generic-metric passes now
always run afterwards. Each per-card generator resolves its own provider
chain and bounds its own provider call, so running them after a failed
comprehensive costs a cheap chain-resolve when no provider is configured
and is capped per card otherwise. The result reports each section's
outcome independently, and a timed-out comprehensive is reported distinctly
from a failed one so a partial warm is observable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f52f8e9c3b32590f3b085d082418ebdd3a923f1d)
The v1.8.6 compliance rework left the last/next-dose and compliance rows in
a plain vertical stack with independently conditional rows. A card missing
its last-dose line (never taken) or its streak flame (zero streak) rendered
shorter, so the dose rows and action buttons drifted out of alignment across
the two-column grid.
Pin the layout on both card variants identically: the Card fills the
stretched grid cell with h-full, the CardContent becomes a flex column, the
next/last-dose pair sits in a reserved min-height slot so a missing line
keeps its footprint, and the intake-actions block is pushed to the bottom
with mt-auto for a shared action baseline. Generic and GLP-1 cards now align
row-for-row regardless of which optional rows render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a7e541eeb5123abb63514f56d12b93b0023ce417)
The app pins DATABASE_URL in the compose environment block, which shadows
any connection_limit an operator sets in .env, so Prisma fell back to its
default pool (cpu_count*2+1 — 9 on a 4-core host). A combined web+worker
process starves that pool under background AI and rollup load, leaving
HTTP requests waiting on the pool until they time out. Set connection_limit
explicitly (default 20, override via DB_CONNECTION_LIMIT) plus a pool_timeout
override, so the pool can be sized to the host and its database.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a plain-language assessment to every HealthKit metric page via a
data-driven generator (archetype templates + per-metric metadata, no
per-metric prompt), a one-tap/auto background pre-generation of all
assessments, and the iOS-facing widening of the Insights layout to the
full metric set. Updates the ChatGPT Codex model-slug ladder so
assessments generate again after the backend rotated its identifiers.
Splits the Targets/Sources settings pages, renames the section to
AI Insights, and applies the metric-detail back-nav, dropdown padding,
heading, and Sources-translation polish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.
Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.
Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
Add POST /api/insights/pregenerate so every AI assessment text — the
comprehensive insight, the seven specialised status cards, and every
data-bearing generic metric assessment — can be warmed for the calling
user on demand, in the active locale, instead of waiting for the nightly
cron or a lazy first-visit generation.
The route narrows userId from the session/Bearer, runs the assistant
surface gate, and enqueues a forced full-warm job onto the existing
insight-pregenerate queue, returning immediately. A short per-user bucket
(insights-warm:<userId>, one per three minutes) returns 429 on a tight
loop so the bypassed nightly 20h budget cannot be turned into a provider
cost amplifier.
The worker branches on the payload: a job carrying { userId, force }
runs forceWarmUser for that user, skipping the candidate scan and the
20h budget; a scheduled tick with no payload keeps its existing batch
behaviour. forceWarmUser reuses the existing comprehensive generator and
warm helpers — no new generation logic — and stays safe by construction:
the comprehensive generator no-ops without a provider, the generic warm
pass filters to metrics the user has rows for so an empty metric never
reaches the provider, and every write is idempotent.
The queue name and a generator-free enqueue helper move to
insight-pregenerate-shared.ts, mirroring the insight-status-generate
split, so the route enqueues without pulling the generator tree.
Regenerate the OpenAPI contract for the new route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e10ae9bfeca544c7e5aecd9102459d97ddfad6db)
Derive `INSIGHTS_TILE_IDS` from `SUB_PAGE_SLUGS` rather than maintaining a
separate 14-id list. The layout tile-id universe and the routed tab strip
now share one source of truth, so a client can persist a layout covering
every metric page the web exposes (37 slugs + the overview mother page)
instead of tripping a 422 on the ~25 slugs the fixed allow-list did not
know about.
Rebuild `DEFAULT_INSIGHTS_LAYOUT` from the derived id list with an explicit
default-visible set; the long-tail HealthKit, body-composition, mobility,
and audio tiles stay default-invisible. The accepted set stays closed — an
unknown id still 422s — and the legacy German aliases keep round-tripping,
so no persisted layout breaks.
Regenerate the OpenAPI contract and extend the route deck: a PUT carrying
the previously-rejected slugs now succeeds, a full-universe PUT round-trips
through GET, and a genuinely-unknown id still 422s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0a161d430533845b0c4c322047701b93aea49a06)
The workouts detail back-link's `ArrowLeft` glyph carried no
`aria-hidden`, so a screen reader announced it alongside the link text. Add
`aria-hidden="true"`, matching the sibling values detail page's back-link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 108c3ff583a707b100570e2999b129055f90c76f)
Splitting Sources back out into a standalone settings page re-exposed the
source-priority editor body — card title, help text, reorder buttons, the
move-up / move-down ARIA labels, per-metric and device-type controls — which
were still English placeholders for es/fr/it/pl. Provide natural
translations so a non-de/en screen-reader user no longer hears English.
Drop the orphaned `settings.sections.sources.redirectNotice` key from all six
bundles; the redirect it captioned was deleted when Sources became a real
route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 945d3ed8a6981c23dc51f45c22a1cd3f2625c859)
The ingest invalidation path mapped a fresh measurement only to the seven
specialised assessment scopes, so a freshly-synced HealthKit metric's generic
assessment card lagged its real-time data until the nightly warm pass —
an asymmetry against the near-real-time specialised cards.
Add a reverse `MeasurementType → metric id` index to the registry and extend
`invalidateStatusInsightsForTypes` so a reading of a type that backs a
registered generic metric also evicts and re-enqueues the matching
`metric:<ID>` scope, using the same single-locale, singleton-debounced
enqueue as the specialised scopes. Unregistered types and the seven
specialised metrics resolve to no generic scope, so the constant sync cannot
fan out to unwanted cards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4)
The generic metric-status generator read its previous-context row through an
`as never` cast, because `getPreviousInsightContext` was typed to the seven
specialised scopes only. Widen the helper's `scope` param to a
`PreviousContextScope` union that also accepts the `metric:<ID>-status` scope
form, so the generic call type-checks honestly instead of silently disabling
type checking on the argument. No behaviour change — the helper only
interpolates the scope into the audit action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 899d59b6fa219a14ba39eacded2e042f0f95c2c5)
The active-energy page passed `ACTIVE_ENERGY_BURNED` as `statusMetric`, but
that is the DB MeasurementType, not the registry id the metric-status route
accepts. The route's closed enum only knows `ACTIVE_ENERGY`, so every fetch
422'd and the assessment card silently fell back to its empty body.
Point the page at the `ACTIVE_ENERGY` registry id and type `statusMetric`
(plus the `useInsightMetricStatus` metric param) to the closed
`MetricStatusMetricId` union instead of a bare string, so a future
MeasurementType-vs-registry-id mismatch is a compile error rather than a
silent 422. A typecheck pass over all 29 pages confirms active-energy was the
only diverging one.
Extract a shared `MetricStatusCard` that owns the `useInsightMetricStatus`
hook and the assessment-card wiring, collapsing the duplicated mount block in
the HealthKit metric scaffold and the bespoke sleep page to a single line
each (only the icon differs). Behaviour is unchanged: the card mounts only on
the data-bearing branch, so a source-less account never fires an assessment
round-trip. Add a test asserting the active-energy page wires an accepted
registry id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6e371aaeb9698f7b4251c22e1d8f381ddfa70a00)
The identity-cast helper had no caller and tripped the dead-code gate.
Remove it and its now-orphaned type import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three related settings changes:
- Split the combined Targets & Sources page back into two pages: Targets
(/settings/thresholds) owns the per-metric target-range editor and
Sources (/settings/sources) owns the source-priority and device-type
ladders. Each is its own left-nav entry and route, served by the
dynamic [section] route. The former /settings/sources permanent
redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
pages.
Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
The select trigger used pl-3 pr-2 (12 px left, 8 px right), parking the
chevron visibly closer to the right edge than the value text sits from
the left. Use px-3 so both gutters are 12 px; the gap-2 between value and
chevron and the chevron's shrink-0 keep it from crowding or clipping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit aaea4ea8102d8f74ea039fc6764ee2e3e5474afc)
The metric values subpage (/insights/values/[type]) and the workout
detail page rendered the back link as the first child of SubPageShell,
which paints it below the page heading. Add an optional backLink slot to
SubPageShell that renders above the header — the standard back-nav
placement — and move both pages' back links into it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72a73a52eb8295c9e70a3456972476b8c4469f07)
The ChatGPT Codex backend rotated its ChatGPT-auth model slugs off the
`-codex` specialist line onto the plain `gpt-5.x` line, so the in-code
default ladder (`gpt-5.3-codex`, `gpt-5-codex`, `gpt-5`, `gpt-4o`) is
now rejected and insight generation fails with a 503 on a fresh deploy
that has no `CODEX_MODEL_FALLBACK_CHAIN` override.
Replace the default ladder with the current accepted set, in order:
`gpt-5.5` (current default across paid tiers), `gpt-5.4` and
`gpt-5.4-mini` (documented fallbacks), `gpt-5.3-codex` (late rung, still
accepted intermittently), `gpt-5.2` (legacy floor). The
fall-through-on-rejection walk, the working-slug cache, and the env
override path are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit bced19de2d4f0788bfd6fa56a78e719347776a1f)
Add an optional statusMetric prop to HealthKitMetricPage that mounts
InsightStatusCard beneath the chart, pointed at the generic
metric-status route. The card renders only on the data-bearing branch;
the insufficient-data branch keeps the existing empty-state note and
never fires an assessment fetch. Wire statusMetric on the 29
HealthKit metric sub-pages and on the sleep page, which closes the
assessment slot its deferral comment had left open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf)
Add a parameterised query key (insightsMetricStatus) and a
useInsightMetricStatus hook that reads the generic per-metric
assessment route GET /api/insights/metric-status?metric=<id>. The
seven bespoke status routes keep their own keys and hook union; this
one covers the HealthKit metric sub-pages that share a single route
keyed by the metric id. The hook mirrors the existing status loader:
8 s client ceiling, no inline retry, and the bounded preparing poll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f)
Extend the assessment-card system beyond the seven specialised scopes to
the ~30 HealthKit metric pages, without hand-writing a prompt per metric.
- Add a metric-metadata registry keyed by HealthKit id (display name,
unit, direction, optional normal range, archetype, scope), covering the
data-bearing HealthKit metrics. The seven specialised metrics keep
their own scopes and are excluded from the generic set; an unregistered
id gets no card.
- Add a generic data-driven generator that reuses the existing graded
snapshot, provider runner, stale-while-revalidate cache, and persist
path. One prompt template per archetype (physiological-vital,
activity-fitness, body-composition, mobility-gait,
environmental-exposure) plus a dedicated sleep template, parameterised
by metric metadata. Empty-data metrics return an insufficient marker
with no provider call. Normal ranges use age + sex where the profile
stores them; no ancestry/region ranges.
- Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped,
Zod-validated against the closed registry id set (422 on unknown),
read-only with stale-while-revalidate. First insights route with
OpenAPI coverage; regenerate docs/api/openapi.yaml.
- Route the generic metric:<id> scope through the on-demand status queue
worker dispatch.
- Extend the nightly warm pass to the generic metric caches for a user's
data-bearing metrics only (one distinct read gates the set), keeping
de+en warm and never calling a provider for an empty metric.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
The long-lived develop branch carried no work absent from main and had
drifted 1216 ahead / 85 behind. Retire it (archived as the tag
archive/develop-pre-v1.5) and rewrite the branch model to match how
releases actually ship: main is the single trunk, releases build on
short-lived release/vX.Y.Z branches off main, merge back, and tag.
This removes the back-merge debt by construction and keeps the tagged
commit on main the single source of the OpenAPI contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pharmacokinetic-estimate disclaimer rendered as a low-contrast muted
italic footnote, easy to miss. The curve is a modelled value, never a
measured blood concentration, and that framing must read clearly. Lift it
to a bordered note with stronger text contrast and weight, keeping the
wording unchanged and the tone informative rather than alarming.
The headline value span is shrink-0 whitespace-nowrap so it never
truncates a digit — correct. A worst-case very wide value could still
push the card's content past its grid column and break the layout. Add
overflow-hidden to the tile card wrapper so a pathological value clips at
the card bound rather than the grid, without re-introducing truncation of
the number for realistic readings.
The write-path regenerate-on-invalidate enqueued a fresh assessment for
both de and en on every dirtying measurement, while the read-path only
ever serves the user's resolved locale. A frequent Apple-Health sync thus
fanned out two provider calls per scope, half for a language the user
never opens. Resolve the user's locale the way the read-path does
(normalizeLocale of user.locale) and enqueue that single locale. The
nightly warm pass still covers both locales for the rare locale switch.
Extract the advisor caption's bordered card + icon + foreground prose
shell into a shared TrendCaptionCard and render the additive-metric
fallback caption through it, so the Trends-row captions read with one
typography instead of the plain muted line standing out below the
chart. The two paths now share a single presentational shell and can't
drift apart.
The small KPI tiles in the dashboard strip clipped the numeric value at
narrow widths: the headline value span carried `min-w-0 truncate` while
the unit span was `shrink-0`, so on a dense strip (many tiles → narrow
grid column) the number — not the unit — shrank below its content width
and ellipsis-clipped, rendering "130 mmHg" as a truncated "13…".
Flip the priority. The value span is now `shrink-0 whitespace-nowrap` so
every digit always renders in full on one line, and the unit span takes
`min-w-0 truncate` so it yields when the column is too narrow. The
paired-value font step-down (text-3xl → text-2xl for BP systolic/
diastolic) stays as headroom.
Add guards asserting the value node never carries truncate/min-w-0 and
the full reading (single and paired) renders inside it.
Lift the GLP-1 drug-level estimate and the dose-strength (titration)
curve out of the default-closed disclosure on both the medication
detail and history pages so they are visible without a click. Render
the drug-level estimate first — it is the curve the medication concerns
— with the titration curve below it.
Render the estimated drug-level-in-blood curve for any recognised GLP-1
medication without requiring the Research Mode opt-in. The chart still
self-gates on a recognised brand and on having intake data, and the
pharmacokinetic-estimate disclaimer is now always attached so the curve
reads as a modelled value, not a measured blood concentration.
Drop the now-dead Research-Mode gated-placeholder branch and its copy.
The researchModeEnabled flag is unchanged; it still gates the advanced
settings surface and its acknowledgment dialog.
The mood assessment rendered last on the page, below the target summary,
the calendar, the distribution, the weekday and correlation breakdowns.
Move it directly under the mood chart so the narration sits next to the
trend it describes, matching the other category pages.
A category open whose today-cache had been evicted dropped to the
preparing skeleton even when a perfectly good assessment from an earlier
day was still on record. Read the most recent non-stub assessment for the
scope and serve it instantly while the worker re-warms the cache; only
fall back to the preparing state when no assessment was ever produced.
The user no longer waits on an open if an assessment ever existed.
The measurement-sync invalidation deleted the dirtied per-scope
assessment rows and waited for the user's next category open (a cache
miss → a worker round-trip while they wait) or the nightly cron to
re-warm them. Enqueue a debounced regenerate for each dirtied scope
instead, coalesced per (user, scope, locale) through the queue's
singleton key so the constant Apple-Health sync collapses a burst of
samples into one regenerate per scope rather than a job storm.
The deprecation notice and the profile-incomplete hint rendered as two
near-identical warning cards stacked on top of each other, so an
incomplete-profile user saw two warning banners. Fold the profile detail
into the single deprecation card as a secondary line beneath the headline
message, shown only when height or age is missing. The page now carries
one warning banner.
The insurerIkNumber field went straight to the 9-digit regex refine with
no upper bound, unlike its siblings insurerName and fullName which cap at
120 characters. Add a defensive .max(32) before the transform — well
above the 9 digits the value ever holds — so an oversized string is
rejected at the length gate rather than reaching the regex. The refine
already rejects malformed input; this is hardening for consistency with
the surrounding fields.
The relocated diversity-nudge lightbulb is a 44px touch target that only
mounts on pages where readings cluster, so the heading row sat taller on
pages with a nudge than on those without — category-page headings did not
align. Reserve a constant min-height on the heading row so the baseline
holds whether or not the glyph is present.
Split the row into a wrapping left group (title, nudge, badge) and a
shrink-0 Coach icon pinned top-right via justify-between, replacing the
ml-auto member of a single flex-wrap row. A long or wrapped title at
375px can no longer isolate or crowd the Coach icon; it stays cleanly
top-right and the title reflows in the remaining space.
On narrow viewports the dot stepper collapsed every per-dot label, so
the user faced an unlabelled row of bullets with no sense of position.
Add a compact caption below the dots that names where they stand ("Step
4 of 8 · How often") on small screens, and let the per-dot labels show
only from the small breakpoint up where there is room. The dots stay
tappable throughout.
`summariseCadence` and `summariseScheduleDraft` each re-spelled the
cadence phrase, its detail clause, and the interpolation count. Extract
a single `{mode, cadence, subControls}`-shaped helper both call so the
medication-wide and per-schedule summaries can't drift apart, and drop
the now-redundant `summaryKeyForCadence` wrapper. Behaviour is
identical; add coverage pinning the per-schedule summary to the
medication-wide summary's leading cadence segment.
The streak flame's flex wrapper rendered unconditionally, so a
zero-streak medication left a residual gap below the two compliance bars
from the wrapper's row plus the stack's vertical spacing. Mount the
wrapper only when there is a streak to show.
The generic and GLP-1 medication cards each re-spelled the
`ComplianceDisplay` interface already exported from the compliance lib.
Drop both hand-copies and import the canonical type so a future field
change lands in one place.
`expectedSlotsBetween` walked the full requested window even for a
medication younger than that window. The legacy weekday expander floors
on `startsOn` but not on `createdAt`, so a two-day-old daily med
reported seven expected doses over a seven-day window and thirty over a
thirty-day window — counting days before it existed. Clamp the window
lower bound to `createdAt` so the expected-dose counts, and the window
selection that reads them, reflect the medication's real age. The
displayed rates already clamp independently via `calculateCompliance`,
so only the slot counts change.
The structured taxonomy tags carry a separate, richer signal than the
free-text tag list: a curated catalog pick with a label key and category,
attached to a mood entry through the tag-link join. The tag-mood takeaway
builder only ever read the flat tags, so the structured tags never reached
the ranked lift/drop pool despite both call sites already passing them.
Merge both axes into one ranked pool. A structured candidate emits its
catalog label key as tagKey, which the feed resolves to the active locale
the same way the weekday key already resolves; flat tags keep their
verbatim label. The lean status snapshot now loads the structured-tag
links too, so the takeaways the model sees match the ones on screen.
The mood page stacked the in-target tile directly above the narrative
feed, and the feed carried a same-number in-target takeaway built from
the identical share — so the percentage showed twice, one row apart.
Suppress the in-target narrative in the feed whenever the tile renders
(the tile is the canonical surface). The takeaway still rides the API
and LLM snapshot payload unchanged.
The health-record FHIR bundle now carries a Coverage resource right
after the Patient. Coverage.status is active, beneficiary references
the Patient, and subscriberId carries the KVNR when present. The payor
is a contained Organization referenced by a local #-ref: its name is
the insurer name and its identifier carries the IKNR under the
http://fhir.de/sid/arge-ik/iknr system.
When the IKNR is absent the Organization keeps its name only; when
neither insurer name nor IKNR is known the Coverage is omitted rather
than emitting an empty payor. The KVNR continues to ride on
Patient.identifier unchanged — Coverage is purely additive.
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.
The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.
Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
Target editing now lives inline on the insights pages, so the standalone
targets page is no longer a navigation destination. Remove its sidebar
and bottom-nav (overflow) entries and add a top deprecation banner that
points the user at the insights pages and notes the page will be removed
in a future release.
The page stays routable: the /zielwerte to /targets redirect remains and
the route still renders. Removal later is UI-only because the insights
panel consumes the same targets read and threshold-override write. New
deprecation strings land across all six locales.
The per-metric reference panel on each insights category page now opens
the self-contained target editor in place rather than linking out to the
targets page. The panel mounts the same <TargetEditSheet> the targets
card uses, seeded with the metric's type, unit, and range (plus the
diastolic band for blood pressure); the sheet writes the threshold
override and invalidates the shared targets cache on save, so the panel
repaints without a navigation.
Switch the sheet's targets-cache invalidation to the query-key factory
call so it stays aligned with the centralised key.
The Trends row charts the metrics the daily briefing flags, but only
the legacy BP / weight / mood triple carried an advisor annotation.
Every additive metric (pulse, sleep, steps, HRV, resting heart rate,
active energy, flights, distance, VO2 max, body temperature) rendered
the chart with empty space underneath because the caption only painted
when a slot carried an annotation key.
Give every chart config a standard one-line description (captionKey)
and render it for slots that carry no advisor annotation, so no card
is ever caption-less. The legacy triple keeps its annotation slot. Add
the caption strings across all six locales and regression tests that
pin a caption on every tile.
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.
The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.
Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
Add an optional per-entry `source` to POST /api/measurements/batch,
restricted to the {APPLE_HEALTH, MANUAL} subset of MeasurementSource and
defaulting to APPLE_HEALTH when absent so existing web and iOS callers
are unchanged. The standalone iOS client can now tag adopt-on-pair
backfill rows that the user entered by hand as MANUAL instead of having
them mis-attributed to HealthKit.
source is threaded into the (userId, type, source, externalId) dedup
key, the existence probe, and the stats:* overwrite scope, so a MANUAL
row and an APPLE_HEALTH row sharing an externalId are distinct rows and
the stats:* re-post overwrite only touches the matching source.
WITHINGS and IMPORT stay server-owned and are rejected here with 422.
Regenerate the OpenAPI spec and add integration coverage for the
absent-source default, explicit MANUAL, cross-source distinctness, and
the rejected-source 422 path.
Replace the space-y-4 + interleaved-separator danger card, whose two
rows the outer group double-framed into unequal spacing, with one
tinted card: divide-destructive/20 dividers, equal py-3 per row, and
two identical destructive buttons for a true matched pair.
Drop the lg:grid-cols-2 data|reminders grid that read as overloaded.
The sheet now narrows from 4xl to 2xl and stacks four bordered section
cards in most-used-first order: Reminders, Lifecycle, Data, Danger. One
spacing rule throughout — space-y-4 between cards, divide-y plus py-3
rows inside each card, with the SettingsGroup card owning the dividers
so no row interleaves its own separator.
The notes timeline display comes off the mood insights surface; note
capture on the entry form stays. Add a structural guard that the
timeline component is gone, the insights sections no longer render it,
and the narrative feed and in-target tile take its place.
Surface plain-language takeaways above the mood charts: the weekday dip
and peak, the overall trend direction, the weekend-vs-weekday effect,
the strongest tag-to-mood lift and drop, the in-target share, and the
current logging streak. Each takeaway is threshold-gated — every minimum
sample count and minimum effect size is a named constant, and a takeaway
only renders once its bar is cleared, so the feed never falls back to
filler. The list is ranked by effect size and capped.
The takeaways are pure helpers computed from the existing mood
aggregates, so the same feed reaches both the insights endpoint and the
mood snapshot the model reasons over — what the page shows matches what
the model is sent. Also promote the previously snapshot-only in-target
percentage to a visible headline tile.
A long free-text note forced the mood entries table into horizontal
scroll. Cap the note column width, clamp the note to two lines, and
surface the full text on hover through a tooltip. The complete note
stays editable in the entry sheet.
The Coach launch sat at the foot of every category page; move it into the
sub-page header as an icon-only button at heading height, top-right, so
"ask the coach" reads as a header action and the page no longer ends on a
stray button. The shared shell mounts it (gated on the Coach flag plus the
per-user opt-out), so every category page picks it up and the foot
instance is dropped. Also drop the purple left accent on the assessment
("Einschätzung") card — the coloured rule made it read as restless against
the calmer surrounding cards.
The measurement-diversity nudge used to render as an inline note block on
each category page; read in context it looked like a data finding rather
than a tip and confused. Move it onto the heading as a small Lightbulb
glyph that only mounts when the spread is genuinely lopsided; the hint
text it used to show inline now lives in a hover / focus Tooltip with an
accessible name. The sub-page shell carries the glyph in the heading row,
drops the removed explainer trigger, and normalises the foot-of-page
"show all readings" control to the standard secondary-button height.
The round `?` popover next to each category heading read as restless
beside the title and duplicated copy already shown inline. Remove the
`MetricExplainer` trigger entirely; the static one- to two-sentence
definition stays as the muted caption directly beneath the heading, so
the definition is still always visible without a tap. The bundle keeps
the per-metric Title leaf for stable key pairs even though only the Body
caption now renders.
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose
adherence timeline on sparse-cadence medications. The timeline breaks the
card layout and reads as restless, so every medication is back to two
percentage rows.
The two windows are no longer hardcoded — they scale with the dosing
cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and
returns the densest rung whose both windows clear MIN_STABLE_DOSES realised
expected doses, so a daily med stays on 7/30, a weekly med steps up to
30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When
even the top rung can't clear the floor (a brand-new or very rare med) the
widest rung is returned so the card still shows two honest rows. The row
labels follow the chosen windows via a parametrised complianceWindow string.
The compliance percentage math is unchanged: each row is the existing
cadence-aware calculateCompliance over the selected window. compliance7 /
compliance30 stay on the wire verbatim for iOS and the Health Score.
- compliance.ts: replace the timeline display with selectComplianceWindows +
the window ladder; buildComplianceDisplay now returns the two scaled rows
- remove the dose-adherence-timeline component and its now-dead strings
- medication-compliance-bars: parametrise the row labels on the day-counts
- switch the standard and GLP-1 cards back to the always-two-row bars
The status pill painted full-saturation Dracula green/orange/red text on
a 12-14% same-hue tint. That fails WCAG AA (4.5:1) for the out tone on
the muted insights reference panel in dark mode (3.87:1) and for every
tone on a white card in light mode (1.2-2.7:1), since the pill used the
raw Dracula palette rather than the theme-aware feedback tokens.
Switch the pill to dedicated theme-aware tokens (status-pill-in/near/out)
and a 10% tint: dark mode keeps Dracula green/orange and lifts the out
tone off #ff5555 to a lighter red; light mode adopts the darker
AA-corrected family already used for the chart and destructive colours.
All three tones now clear 4.5:1 on both bg-card and bg-muted/30 in both
themes (worst case 4.62:1).
DataSummary.median carries two windows under one field name: the SQL
summaries-slice path windows PERCENTILE_CONT to the trailing 90 days,
while the JS summarize() takes the median over the full series it is
handed (a caller-defined window). No user-visible bug today — the stat
strip only reads the 90-day SQL path — but a future consumer reading
summary.median off a summarize()-derived payload would silently get a
different number. Document the window on the type and at both call sites
so the divergence is explicit; the percentile algorithm matches, only
the window differs.
The blood-glucose reference panel fans out to up to four per-context
panels (fasting / postprandial / random / bedtime), each previously
emitting its own identical "Adjust target range" link to /targets — so
the page stacked up to four copies of the same link. Lift the link to
the group wrapper and render it once; keep the per-panel link for the
non-glucose single-panel path.
A mood level with no days painted a zero-height bar with no affordance,
reading as missing rather than "0". Add a faint full-height track behind
each bar so empty buckets stay legible; the coloured bar paints over the
track for the populated levels.
The structured-tag picker chips and the side-effect chips sat at 32px
(min-h-8), under the 44px minimum and a fat-finger risk on a phone where
they wrap tightly. Bump both to min-h-11 with matched padding so the tap
target clears 44px without loosening the visual density.
The five mood-metric correlations and the measurement query's type
filter were two hand-kept copies of the same channel list. Introduce a
CORRELATION_METRICS map and single-source both the correlations object
and the fetch filter from it so they cannot drift.
The list GET surfaces tagKeys per entry but the create and update
responses omitted it, so a client hydrating from the response body
showed an empty tag set until the next refetch. Read the persisted
link keys back inside the write transaction and include them in both
response bodies.
The mood entry was committed before createTagLinks/replaceTagLinks ran,
so a tag-link failure 5xx'd after the entry already existed and a client
retry minted a duplicate entry. Wrap the entry write and the tag-link
write in one $transaction and thread the tx client through both helpers
so they commit or roll back together.
The structured tag catalog is seeded directly by migration
0101_v185_mood_tag_taxonomy and served from Postgres via the
/api/mood/tags route, so the parallel TypeScript copy had no
importer and tripped the knip gate.
The injection-site / log-dialog / settings cluster shipped translated in
de and en but verbatim English in es, fr, it and pl, so those users saw
English copy in the medication wizard, the log-injection-site dialog and
the settings injection card. Localise the eleven keys and drop the two
orphaned injection-rotation keys in the same bundles.
medications.injectionRotationOverviewTitle and
medications.injectionRotationNextSuggested have no t() reference anywhere
in the tree — leftovers from an injection-rotation overview that did not
land. Remove them so the bundle carries only live keys.
The SVG site picker painted its focus ring as the stroke of a transparent
hit-target circle — effectively invisible on the muted body map, a WCAG
2.4.7 risk — and conveyed the recommended next site only through the
dashed ring (no screen-reader cue). Track keyboard focus and paint a
contrasting halo on the filled dot, and fold the recommendation into the
recommended site's aria-label so the rotation guidance reaches assistive
tech. Adds injectionSiteRecommendedAriaLabel across all six locales.
expectedSlotCountForDay and expectedSlotsBetween each rebuilt the same
canonical-schedule and recurrence-context literals inline, differing only
by id prefix and count-vs-collect. Extract toRecurrenceCtx and
toCanonicalSchedule so both share one adapter; behaviour identical.
resolveInjectionSiteForWrite reimplemented the effectiveAllowedSites(...)
.includes(...) membership test inline, duplicating the isSiteAllowed
helper that already wraps it. Call isSiteAllowed so the write-path guard
and the helper share one definition.
A rolling cadence with no intake logged anchors its first dose at
startsOn ?? createdAt. When that anchor falls on a prior calendar day
the dose is overdue by a day or more, but nextOccurrenceAfter floored
the search window to the start of the user's current day and dropped it
— so the dashboard showed no next-due and the reminder worker, which
shares this engine, emitted nothing. Clamp the floor to the first-dose
day in the no-intake branch so a multi-day-overdue start dose surfaces
as overdue. The post-intake re-anchor (lastIntakeAt + N) is unaffected.
Six HealthKit metrics without a published LOINC term were coded under
http://loinc.org, which fails FHIR R4 conformance (a non-LOINC code must
not sit in the LOINC namespace). Route any HKQuantityTypeIdentifier code
onto a shared custom CodeSystem (https://healthlog.dev/fhir/CodeSystem/healthkit),
keeping the raw HealthKit identifier as the code so the export stays
byte-aligned with the native client's exporter.
Grow the per-category insights target summary from a one-line range card
into a compact reference panel that surfaces the band range, the verbal
status, the guideline source, the in-target share, the 30-day average,
the range bar and the 7-day consistency strip — all from the Targets
data already on the wire, reading the same shared cache. Extract the
status pill and the source-link resolver into shared primitives so the
Targets card and the insights panel can never disagree, and wire blood
glucose into the summary with its per-context split and unit conversion.
The overview Trends row used to render a fixed BP / weight / mood
triple regardless of what the day's data surfaced. It now mirrors the
daily briefing: the row charts exactly the metrics the briefing flags,
in the briefing's priority order, deduped and capped at three. When no
briefing is available — cold mount, a web-only account with no
findings, or a pre-briefing cached payload — selection falls back to
the legacy BP / weight / mood triple so the row never paints empty.
Selection lives in `trend-chart-select.ts` as a pure, unit-tested
mapping from each briefing `sourceMetric` to a chart descriptor
(series, colour, unit, title key). Adherence-context findings
(compliance, GLP-1 plateau) have no standalone trend series and are
skipped so the next finding fills the slot. Annotations stay on the
legacy triple; additive HealthKit metrics render chart-only. The chart
set is derived from the briefing payload already on the page, so there
is no new blocking call.
Add the data-value features to the insights category pages on top of the
density pass:
- Stat strip: lead each metric page with min / max / median / mean,
read from the already-fetched analytics summaries slice (no new
network). Median is now computed in both summary producers — exact in
the live path, a windowed PERCENTILE_CONT in the rollup-tier slim
slice. The thin HealthKit pages mount it too for parity; no AI
assessment is added there.
- Range band: shade an Apple-Health-style min-max area around the daily
mean line where the data supports it (non-cumulative metrics). The
daily-aggregate read now emits per-day min / max from the rollup
buckets; the band rides the existing target-range toggle.
- All readings: a dedicated per-metric subpage at /insights/values/<type>
reusing the measurement list pinned to one type (pagination + inline
edit/delete). A clear entry button mounts at the foot of each
category page.
- Diversity nudge: a gentle hint when a metric's readings cluster on one
weekday or time of day, computed from a bounded, cached timestamp
window — non-blocking, self-gating.
All new reads stay cache-friendly and off the navigation path.
Add the Category -> Tag taxonomy the mood surface was missing and the
free-text note the web form could never write, then surface both on the
Insights mood page.
Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog
plus a MoodEntryTagLink join, seeded with a default five-category set
(i18n-keyed labels, Lucide icons). Purely additive — the legacy flat
MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram /
MoodLog sync are untouched; an entry can carry both axes.
Capture: the mood-logging form and the edit sheet gain a Textarea for
the note (model + API already accepted it) and an icon-chip picker over
the taxonomy catalog (GET /api/mood/tags). Create / update resolve the
picked keys to links; unknown keys drop silently against the catalog.
Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and
a notes timeline join the existing distribution / weekday / flat-tag
sections. The aggregate extension also fixes the flat-tag axis, which
read the raw JSON string and so never populated.
i18n across all six locales. Tests cover the taxonomy aggregation, the
note round-trip, the structured-tag schema, and the notes timeline.
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.
- Schema: add medications.track_injection_sites + allowed_injection_sites
and users.global_excluded_injection_sites (migrations 0099, 0100;
additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
status-toggle, and bulk intake routes. Persist it only on a taken
injection with tracking enabled, validated against the medication's
effective allowed set (per-med allowed sites minus the user-level
global exclusion, deny wins) — a disallowed site returns 422 (the bulk
route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
wizard step, a skippable post-dose site prompt on both medication
cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.
The site capture is always optional and never blocks logging a dose.
Lift the mood aggregates the AI status generator already computes —
tag frequency + per-tag average, in-target share, and the
mood × metric Pearson correlations — into a shared
`mood-aggregates.ts` so the prose and the new charts read off one
implementation. `mood-status.ts` now consumes the shared helpers
instead of its own inline copies.
Add a cached `/api/mood/insights` endpoint that returns the
pre-computed shapes (summary, heatmap, distribution, weekday, tags,
correlations) behind a 60-second server cache and a bounded 365-day
read — a cheap read-only path, never a synchronous model call. The
bucket invalidates alongside the mood-analytics cache on a mood write.
Render the shapes on the mood Insights page:
- a calendar heatmap forked from the compliance heatmap, coloured by
the same mood-score bands as the line chart, with an adaptive
window (30 days until ~90 days of history, then 90, then a full
year past 180 days) so the grid is never mostly empty;
- a mood distribution bar chart (share of days per level);
- average mood by weekday with a best/toughest-day callout;
- a tag / trigger breakdown (frequency bars + per-tag average);
- mood vs. sleep / activity / pulse correlation cards reusing the
existing scatter chart.
Add the supporting i18n keys across all six locales.
A rolling 7-/30-day percentage is meaningful for daily and weekday
medications but noise for sparse cadences: a med dosed every few weeks
has at most one expected dose in a 7-day window, so the bar swings
between 0 and 100 off a single sample.
Decide the display server-side from expected-dose density. When the
30-day window holds at least four expected doses the percentage bars
stay; below that the card swaps to an uptime-style per-dose timeline
where each cell is one expected dose — taken, missed, skipped or
upcoming — with a "last N doses" summary and a dose-indexed streak.
The compliance route returns an additive complianceDisplay block
(mode, expected7/30, the dose timeline, the summary) alongside the
existing compliance7/30 fields, which the iOS client and the Health
Score read unchanged. Read-path only; no schema change.
- compliance.ts: add expectedSlotsBetween and buildComplianceDisplay
over the canonical recurrence engine
- dose-adherence-timeline: plain flex cells reusing the heatmap colour
tokens and tap-to-pin tooltip
- switch the standard and GLP-1 cards on the server-decided mode
Extend the daily-mean consolidation to cover the high-frequency
gait/mobility metrics that previously mapped to no consolidation set and
piled up at sampling granularity: walking asymmetry, walking
double-support, walking steadiness, and walking heart-rate average. The
correct daily reduction for these is the mean, matching the Health-app
display; all four stay disjoint from the cumulative (sum) set.
Fold the daily-mean drain onto the existing nightly cumulative-drain tick
so new high-frequency spot samples no longer accumulate raw between worker
reboots. The two passes share one concurrency-1 cron, keeping the
maintenance window a single sequential walk. Boot-time discovery stays as
the back-fill for accounts that accumulated raw rows before this shipped.
Recompute the affected day's rollup bucket after the consolidation
commits, the same way the ingest path does, so a rollup-covered read
reflects the single consolidated row rather than a stale mean derived from
the now soft-deleted per-sample rows.
Regular heart-rate / pulse stays raw: it is a discrete clinical reading
and feeds correlation and scatter analytics. No schema change.
Polish the Insights surfaces for a denser, more deliberate rhythm.
Category pages:
- Collapse the double caption under the heading. The explainer
definition and the description paragraph used to stack, so every
metric page opened with two near-duplicative muted lines before any
data. Keep the explainer body as the single caption; render the
description only when no explainer is set.
- Tighten the header gap (space-y-1.5) and the block rhythm
(space-y-4 md:space-y-5) so the heading-to-first-data distance
shrinks by roughly one text block.
- Demote the target summary from a full bordered card to an inline
band (bg-muted/30 rounded-lg px-3 py-2). One line of range text no
longer reserves the card primitive's py-4/py-6 shell padding.
- Close the gap under the assessment heading. The card primitive's
gap-4/gap-6 flex gap floated the title well above its prose; override
to gap-2 md:gap-3 with py-4/py-5 across every status variant.
Overview hero:
- Condense the "ask the coach" band — denser prompt chips and a
bottom-pinned strip so the left column stops out-running the
Health Score card.
- Give the Health Score breakdown more vertical presence (looser row
rhythm, taller component bars) so the right column fills the row
height instead of leaving the bottom empty.
The schedule wizard's time-of-day step rendered the four suggested times
twice: once as the icon chips from the step itself, and again as the
labelled preset row built into the chip component below. Add an opt-out
on the chip component and pass it from the step so only the icon-based
suggestions remain. The period is still conveyed by the icon plus an
aria-label; the offered times and selection logic are unchanged. The
preset row stays on by default for every other caller.
Deleting a medication from the danger zone awaited a prefix-invalidate of
the "medications" key bundle before navigating. That bundle covers the
detail page's own active query, so the invalidation refetched the just-
deleted resource, hit a 404, retried with backoff, and never resolved —
the confirm button spun forever even though the delete had persisted.
Evict the detail query before invalidating, navigate to the list first,
then fire-and-forget the dependent-bundle invalidation. The busy state is
also cleared on the failed-response path. The detail query opts out of
retries so a deleted-resource refetch never burns a backoff cycle.
Rolling schedules anchored next-due on start + N days when no intake had
been logged yet, which skipped the dose due AT the start date and
suppressed its reminder (the worker shares this engine). The +N cadence
now begins only after the first intake is logged: with no intake the
first dose is due at startsOn (or createdAt). A past start date with no
intake surfaces the dose as due/overdue rather than rolling it forward —
nextOccurrenceAfter floors the rolling search to the start of the user's
day so an overdue or due-earlier-today dose stays visible.
The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are
unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected.
The medication card reads the schedule engine's next-due (fixes
interval/one-time schedules that never advanced after a dose), logging a
dose confirms itself, Insights status polling is bounded, each Insights
category explains itself inline, medication history shows each dose's
origin, and an idempotent migration adds the app_settings columns that
were only ever applied ad-hoc.
Two gaps stopped a fresh demo seed:
- medication_intake_events gained a NOT NULL updated_at column in the
v1.7.0 sync keyset work. The raw INSERTs omitted it; set it alongside
created_at on every intake row so the seed does not lean on the
column's migration-only default.
- app_settings carried four columns on the schema (default_locale plus
the telegram/ntfy/web-push global toggles) that never landed in the
migration history — applied via db push to existing environments but
never committed. A database built from migrations alone is missing
them, and the admin settings read path selects default_locale, so a
fresh deploy reports "column default_locale does not exist" on first
read. Add an idempotent ADD COLUMN IF NOT EXISTS drift-fix migration
mirroring 0025_user_locale_drift_fix: a no-op where the columns exist,
a clean add on a migrations-only build.
Verified end to end against a fresh Postgres 16 built from the full
migration set: the seed completes and re-runs cleanly.
The per-metric status cards and the inline medication-compliance query
poll the read-only status route every 4 s while the payload reports
preparing:true. A configured provider whose generation persistently
fails leaves preparing pinned true, so an open Insights page would poll
indefinitely — steady battery and network draw on a screen the user has
walked away from.
Cap the poll at 12 attempts (~48 s) through a shared
nextStatusPollInterval helper both call sites route through. Past the
ceiling the card settles on its existing static / empty state. The happy
path is unchanged: a generation that lands within a couple of intervals
clears preparing and stops the timer exactly as before, and a terminal
no-provider payload never carried preparing so it stays terminal.
The intake history surfaced the coarse IntakeSource enum directly, so a
dose logged from the iOS app showed nothing meaningful. Map the enum to
the practical client it represents — WEB to Web, API to iOS App,
REMINDER to Reminder, IMPORT to Import — and render it as a muted
"via {origin}" caption on each intake row next to the status. No schema
or API change; the source value already rides the existing intake
response. Tighten the en/de labels that read "External API" and
"Telegram / Reminder" into the same origin vocabulary across all six
locales.
The per-category explainer text only lived behind the "?" popover next to
the heading. Render the same localised definition inline as a muted caption
directly beneath the title, where the short scaffold line already sits, so
the one- to two-sentence definition is discoverable without a tap.
Both surfaces read the identical insights.subPage.explainer.<metric>Body
key, so there is no duplicated copy and all six locales stay covered. The
"?" glyph stays in place as the always-available pointer to the same text.
HealthkitMetricPage already forwards explainerMetric to the shell, so every
routed metric sub-page picks up the inline caption from this one change.
Two fixes on the medication cards.
Next-due: the card recomputed "next intake" client-side from
`daysOfWeek` + `windowStart` alone, ignoring rolling intervals, RRULE,
one-shot doses, and the last-intake anchor. A rolling medication (e.g. a
35-day interval) showed "tomorrow" and never re-anchored after a dose.
`GET /api/medications` already returns `nextDueAt` per medication,
computed by the canonical recurrence engine anchored on the last intake.
Both card variants now render that value directly: the generic card
drops its `getNextOccurrenceTimestamp` walker, and the GLP-1 card drops
its weekly-only predictor that hardcoded a 7-day step. The day-label and
window-range rendering are unchanged; only the timestamp source moves to
the server. `nextDueAt` is threaded through the list page's medication
type into both cards.
Intake confirmation: tapping Taken / Skipped now raises a toast naming
the medication ("… recorded as taken" / "… skipped"), so the action has
clear feedback. The next-intake line refreshes on the invalidated list
query, which the next-due fix above makes accurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-category Insights assessments are served read-only from cache and
generated out of band, the client request is capped, the overnight warm
pass keys on the shown language, a stalled model is briefly remembered,
and Insights/dashboard stream a loading skeleton — so opening and
switching Insights categories stays responsive instead of freezing.
Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.
Fix the class holistically:
- Bound every status GET on the client. The shared use-insight-status
hook and the inline medication-compliance query now wrap fetch in an
8s AbortController and stop retrying inline; navigation can no longer
await an uncapped round-trip.
- Make the status routes read-only. On a cache miss the generator
enqueues an out-of-band pg-boss job (insight-status-generate) and
returns a preparing envelope; the client polls a bounded interval
until the worker warms the cache. Models the dashboard snapshot's
ready/preparing pattern. The queue is registered in the worker
allQueues loop + a boss.work handler, with a guard test.
- Fix the nightly warm-pass regression: warm every supported locale
(the client reads against the active UI locale, not the persisted
User.locale) and warm on a cached comprehensive outcome too (its
write still evicts the per-status caches). Add a short-TTL negative
cache on the timeout path so a transient provider stall no longer
makes every subsequent visit re-block or re-enqueue.
- Add loading.tsx for the insights segment + the root so any future
RSC await streams a skeleton instead of blocking the transition.
- Stabilise the advisor regenerate callback so a status-query flip
can't re-render the memoised tab strip mid-gesture.
New i18n key insights.assessmentPreparing across all six locales.
Guarantees a single intake entry per scheduled dose: intake writes snap
to the canonical slot instant and update in place rather than inserting a
sibling row, a re-sync can no longer clear a recorded dose, concurrent
logs converge, and a one-time startup pass collapses existing duplicates.
Review of the slot-convergence change surfaced gaps that could mis-record
a dose. Fold the write logic into one shared `applyCanonicalSlotWrite`
used by both intake routes and close them:
- Concurrency: catch the unique-key race on insert and converge by
re-finding the slot row and updating it, instead of returning 500 (and
dropping the tap) or leaving a duplicate.
- No downgrade: a pending projection echo replayed onto a slot already
marked taken or skipped is a no-op — it never clears `takenAt`. A
recorded dose is immutable, so an offline re-sync cannot flip a taken
dose back to missed.
- Inventory: decrement a medication's remaining count only on a genuine
pending→taken transition, not on an idempotent re-post.
- Deterministic slot-row selection: prefer an existing actioned row, then
the pending row, ordered by createdAt then id.
- Snap instant: derive the canonical instant via the same `localHmAsUtc`
the projector and reminder worker mint with, so the value is identical
by construction across a DST transition rather than only test-pinned.
- Cleanup job: isolate each slot's collapse in its own try/catch and
guard the instant-normalisation against a tombstone collision, so one
slot can no longer dead-letter a user's whole dedup pass.
The write-path convergence stops new duplicate slot rows, but accounts
that already accrued a pending + taken pair for one dose keep an inflated
expected count, a phantom taken marker, and a suppressed "due now" prompt
until the bad rows are cleared.
Add a boot-time, idempotent cleanup queue (mirroring the existing
backfill queues): discover only the users holding a same-slot duplicate,
then per medication snap every live intake to its canonical slot, keep
the most-actioned row per slot (taken over skipped over pending),
normalise its instant, soft-delete the losers, and recompute the
affected compliance rollups. Rows that resolve to no scheduled slot
(PRN / as-needed) are never collapsed. A second pass is a no-op.
Registered in the worker's queue list with a static guard test, after a
prior incident where a queue was built but never registered and silently
never ran.
A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.
Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:
- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
scheduled slot for that day (enumerated through the canonical
recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
anchors it on the same `localHmAsUtc` the projector and worker use.
Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
`(userId, medicationId, scheduledFor)` ignoring source and update it in
place — mirroring the status-toggle route — instead of inserting a
sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
carries an actioned row (taken or skipped) from any source, not only an
existing pending REMINDER row.
PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
Closes the two surfaces the 1.7.3 multi-time compliance fix did not
reach: the today-tile projection now mints one pending dose per
scheduled time, and the compliance heatmap classifies each dose against
its nearest scheduled time instead of the first window.
The History compliance heatmap matched each taken intake event to the
closest schedule by `windowStart` alone, ignoring `timesOfDay`. For a
twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` and `windowStart = "07:00"`, the evening
dose matched the lone 07:00 window and classified as very-late (~12h),
painting the day's cell orange even when both doses were taken on time —
so the heatmap never recovered to green for single-row multi-time
schedules.
Expand every schedule into its effective dose slots (its `timesOfDay`,
else `[windowStart]`) and classify each event against the nearest slot's
window — the slot time anchoring a window that spans the schedule's
`windowEnd − windowStart` duration, overnight spans included. Single-time
schedules collapse to one slot at `windowStart` and stay byte-identical.
The denominator already routed through the canonical engine; only the
numerator's timing classification is corrected.
The today-window projector minted a single pending intake row per
schedule, anchored at `windowStart`, dropping a schedule's first-class
`timesOfDay`. A twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` therefore surfaced only its morning
dose in the today tile and the "Erfassen" sheet, and the event-count
compliance rollup recorded one expected dose per day instead of two — a
perfectly adherent user read 50% the moment the second dose was logged,
and deleting then re-adding one dose left the rate stuck at 50%.
Fan the projection out over every `timesOfDay` entry, mirroring the
reminder worker's per-slot mint and dedup against the
`(userId, medicationId, scheduledFor, source)` unique index. Absent
first-class `timesOfDay` the projector still emits one slot at
`windowStart`, byte-stable against the worker's legacy single-window row.
Adds regression coverage across the projector (dashboard summary), the
legacy compliance walker, and the cadence expansion pinning the
single-row twice-daily case at the correct two-dose-per-day shape.
A medication whose schedule row carried multiple timesOfDay plus a legacy
daysOfWeek (and no rrule / rollingIntervalDays) reported 50% compliance for a
fully-adherent 2x/day dose. The expected-slot denominator always ran the
canonical recurrence engine and counted both daily slots, but the numerator
only delegated to the engine for rrule / rolling / one-shot / non-SCHEDULED
rows; a plain-legacy row fell to the local cadence walker, which emitted a
single windowStart slot per day and ignored timesOfDay. 1 taken / 2 expected
read as 50 percent.
Route expandScheduleSlots through the engine for every schedule shape whenever
a medication context is supplied, so the numerator and denominator read the
same source of truth. Harden the context-less legacy walker to iterate
timesOfDay as well, mirroring the engine's legacy branch, so the single
remaining non-engine path stays correct for multi-time rows. Drop the now-dead
expander gate helper.
Add regression guards: a 2x/day legacy row reports 2/2 through
calculateCompliance, and expectedSlotCountForDay agrees with
buildCadenceTimeline on the slot count for an identical schedule.
Four layout defects in the clinical PDF renderer:
- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
outside it (the trend arrows the summary injects, the superscript-two in
kg/m2) resolved to the .notdef box at a fallback advance width and stretched
the surrounding words. Route every drawn string (direct text, wrapped
paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
arrows render as true glyphs is a documented follow-up.
- The sparkline dropped each reading's timestamp and plotted on an index
axis, so a trend gave no sense of its time span. Plot points by their real
timestamp and print the first/last dates under the baseline.
- No consistent bottom safe area: scattered y > 240 magic numbers and tables
with no bottom margin let content run into the footer. Introduce a single
contentMaxY derived from the page height, footer band, and bottom margin;
every break check and every autoTable bottom margin refers to it.
- Modules tore mid-block. Add an ensureSpace guard called before each module
heading and a per-line guard in every free-text loop; give the short
titration and side-effect tables rowPageBreak: avoid.
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.
Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
The warm pass forced the seven per-metric status generators strictly
back to back per user, so a fully configured account paid ~7× one LLM
round-trip serially. Bound the inner pass with a small `p-limit(3)` so
the cards warm a few at a time without saturating the Prisma pool or
fanning out an unbounded number of provider calls. The per-user budget
gate still bounds total cost per night.
The targeted status-insight invalidator only fired on insert and batch
ingest, so editing or deleting a reading left the affected per-metric
assessment cards serving their pre-mutation text for the rest of the day.
Add the fire-and-forget invalidation to the single-measurement PUT
(covering both the old and the new type when an edit re-types a row),
the single-measurement DELETE, and the iOS by-external-ids
reconciliation DELETE (keyed off the distinct types it tombstoned).
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.
Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.
Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.
Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
Two small polish items on the insights category surface.
The desktop explainer popover body rendered at 11px while the mobile
sheet used the larger `text-sm`, an uncomfortable and asymmetric read.
Lift the popover body to `text-xs` (12px) so both surfaces sit on the
type scale.
The `MetricTargetSummary` docstring claimed both the range and the
in-target share are hidden when the route flags `insufficientData`. The
range stays visible — only the share is suppressed. Correct the comment
to match the code.
`<MetricExplainer>` resolves its copy through a template literal
(`insights.subPage.explainer.${metric}{Title,Body}`), so the static
call-site coverage guard never sees these keys — a typo in an
`explainerMetric` prop would paint the raw key path with no CI signal.
Walk every `explainerMetric="…"` value under `src/app/insights/**` plus
the shared healthkit-metric-page scaffold and assert each resolves a
Title and Body leaf in messages/en.json. Also pin the literal-only prop
contract and a non-empty match count so a future rename can't silently
disable the guard. The locale-integrity test then carries the EN
guarantee to the other five bundles.
Two parts to the explainer surface, landed together because they share
the same i18n block and page props.
Coverage: every routed insights category page now passes an
`explainerMetric` prop, so the heading-side `?` glyph is present on all
37 pages instead of 13. The 24 newly covered metrics — body
composition (body water, bone/fat/muscle/lean mass, fat-free mass,
visceral fat), mobility (flights, walking distance/speed/steadiness,
asymmetry, double-support, step length, walking heart rate),
cardiovascular (pulse-wave velocity, vascular age), hearing
(environmental + headphone levels, loud-sound events), respiratory
rate, daylight, blood glucose, and skin temperature — each gain a one
to two sentence layperson definition grounded in standard clinical
references, shipped in all six locales.
Keys: the explainer key segments were a German/English mix
(`blutdruck`, `puls`, `gewicht`, `schlaf`, `stimmung`, `medikamente`
alongside English ones). Rename them to English to match the
naming-convention ADR — `bloodPressure`, `pulse`, `weight`, `sleep`,
`mood`, `medications` — across every locale, and update the
`explainerMetric` prop values that reference them. These are i18n key
segments, not route slugs, so the slug contract is untouched.
The hard '~5 measurement points' floor did not fit mood (counted in
entries) or medication adherence (counted in scheduled doses). Re-word
the gate as 'a few measurement points/entries/events' and note that the
unit depends on the metric. Also fix the gating-field path: the recency
field lives at dataCoverage.newestMeasurementDaysAgo, not on summary.
The mood card noted persistent low phases but offered no gentle path to
support. When mood stays clearly low for several days, the model now
offers an autonomy-preserving pointer toward talking it through with a
trusted person or a professional — never alarming, never a diagnosis, no
emergency tone. The safety-contract matrix does not cover this pathway
and does not reach the per-metric assessment prompts, so it lives here.
For an infrequent schedule such as a weekly injection, the 7-day
compliance figure is dominated by a single dose: one miss swings the
percentage sharply and reads as a collapse it is not. Tell the model to
lean on compliance30 as the load-bearing value when schedulesPerDay is
low and treat compliance7 as a rough pointer only. Output stays {summary}.
The overall-status card summarises up to six metrics, but it inherited
the single-metric 30-60 word budget, which forced the model to either go
generic or overrun. Grant the section an explicit 3-5 sentence / 50-80
word budget and cap it at naming two metrics by name, with the rest
mentioned only when they deviate notably from their own mean.
The bmi and weight cards produced near-identical trend text for a user
with both metrics, since bmi is weight scaled by a constant. Re-aim each
card so they carry distinct information: bmi now centres on WHO-band
placement (call out a band crossing or a near-boundary value against the
person's own baseline), and weight stays on the continuous trend and
pace (kg/week, plateau, milestone). Each defers the other concern to its
sibling card.
Regenerate `docs/api/openapi.yaml` so the `InsightsLayoutBody` enum
carries the canonical English tile ids first, then the
accepted-but-deprecated legacy German ids, with the normalisation +
deprecation spelled out in the schema description. Add ADR 0001 fixing
the convention — localised UI copy, English internal identifiers — and
the non-breaking alias strategy. Add an iOS coordination note covering
the new canonical ids, the continued acceptance of the legacy ids, and
the recommended migration path.
The tile-layout enum migrates from German to English to match the route
slugs. The contract on `GET/PUT /api/insights/layout` stays
non-breaking for clients that persisted the old German ids (the iOS
layout sync among them):
- the Zod enum is built from `ACCEPTED_INSIGHTS_TILE_IDS` (canonical
English plus the legacy German aliases), so a PUT carrying old ids
validates rather than tripping a 422;
- `normalizeInsightsTileId` collapses each legacy id onto its canonical
English replacement on both the read path (`resolveInsightsLayout`)
and the write path (`serializeInsightsLayout`), so a pre-rename layout
surfaces English ids on GET without a forced re-PUT and a legacy PUT
persists English ids;
- a layout carrying both a legacy and a canonical id for one tile
dedupes to a single canonical entry.
The legacy ids are accepted-but-deprecated. Tests cover the read-side
normalisation and the write-side 200 (no 422) + canonical persistence.
Rename every routed Insights sub-page slug from German to English
(`/insights/blutdruck` -> `/insights/blood-pressure`, `gewicht` ->
`weight`, `medikamente` -> `medications`, and the full v1.7.0 metric
tail). UI copy stays localised through `t()` keys; only the internal
slugs change. The slug registry in `src/lib/insights/sub-page-metric.ts`
is the single source of truth, so the tab strip, target summary, and
daily-briefing routing targets follow it.
`next.config.ts` `redirects()` emits a permanent redirect from every
legacy German slug to its English target, including a `:path*` variant
so nested routes survive. Bookmarks and cached PWA navigation keep
resolving.
Drop the dead-field guidance (avg90/allTime, historicalComparison,
ratePressureProduct, bodyCompositionDivergence, seasonalVariation) and
the chart-token instruction. Describe the per-type measurementSeries map
and the adherence/mood/target context, pull them into one coherent
picture against the person's own baseline, and close with the single
most important doable step.
Replace the historicalComparison and moodAdherenceRisk guidance with the
actual per-medication snapshot fields (compliance7/30, streak,
taken/skipped/missed, dailySeries). Read the recent direction as
compliance7 vs compliance30 against the person's own baseline, frame
positively, and close with one blame-free doable step.
Describe the graded mood series, target band and tag list, lead on the
weekly pattern against the person's own baseline, name persistent low or
high phases calmly as an observation rather than a diagnosis, and close
with one kind doable step.
Describe the graded BMI series and target band, lead on the trend over
the single value, keep the WHO bands as placement anchors rather than a
label for the person, and name BMI's body-composition limits plainly.
Drop the chart-token instruction the status path strips out.
Replace the dead-field guidance (historicalComparison.weight,
allTimeMax, slope30, bodyCompositionDivergence, sleep.avg7) with the
graded weight series. Lead on the weekly/monthly trend rather than the
single value, define plateau and sustainable pace against the person's
own baseline, anchor milestones to their own maximum, and gate BMI
context on a known profile height.
Drop the stale snapshot vocabulary (avg7/30/90, historicalComparison,
slope30, rate-pressure product) and the chart-token instruction the
status path strips out. Describe the graded pulse series and target
band, compare the recent days to the person's own weekly/monthly
baseline at a ~5 bpm significance threshold, and frame a rising or
falling resting pulse as a possible explanation rather than a diagnosis.
The status cards render only the model's summary field, yet the prompt
described a snapshot vocabulary (avg7/30/90, historicalComparison,
ratePressureProduct, seasonalVariation) that the graded snapshot no
longer carries, and demanded a full findings/correlations JSON schema
that this path never reads.
Rebuild the shared base prompt around the actual graded shape (summary +
recent/weekly/monthly/yearly with min/max/mean/slope), the own-baseline
comparison, a name -> place -> one-step structure, a warm
autonomy-supporting tone, a 2-4 sentence length, honest data-gating, and
a { summary } output. The blood-pressure section judges systolic and
diastolic together against the person's own baseline with ESH 2023 as
the placement anchor.
Hang `invalidateStatusInsightsForTypes` off every measurement ingest
path so a new reading of a type drops the cached assessment rows that
reading dirties — the next mount or nightly warm pass then regenerates
them against the new data instead of serving the pre-measurement text
for the rest of the day.
Wired into both `/api/measurements` modes (single + multi-entry batch),
`/api/measurements/batch`, and the three Withings sync paths
(measures + activity + sleep), reusing the `collapseToTypeDayKeys`
result each already computes for its rollup recompute. The call is
fire-and-forget after the user's write commits: idempotent, never a
blocker on the request path, and a failure only warns.
The comprehensive generator evicts the seven `insights.<scope>-status`
caches as part of its write so a stale comprehensive insight can never
leave a stale per-metric card behind — but it does not re-fill them.
Before this, the morning after the nightly pre-gen cron ran the
per-metric status caches were empty, so the first `/insights` mount
still ran one live status generation per card.
After a successful comprehensive generation the cron now forces the
seven `*StatusForUser` generators so their caches are warm, not empty,
by the user's first visit. The warm pass only runs on a `generated`
outcome (a no-provider `skipped` has nothing to warm; a `failed` run
means the chain is unhealthy this tick — re-running it seven more times
would only multiply the failure). Each generator resolves the provider
chain internally, so a provider-less account stays cheap. The per-user
budget bucket that already bounds the comprehensive pass also gates the
warm pass — both run behind the same once-per-20h gate.
`runInsightPregenerate` now reports `assessmentsWarmed` and emits an
`insights.pregenerate.run` wide-event with the full per-run tally so the
nightly dashboard can track how many assessments each tick wrote.
Also adds `invalidateStatusInsightsForTypes` + `PER_STATUS_SCOPES` to
the comprehensive generator: a type → scope map (WEIGHT → weight + bmi +
general, BP → blood-pressure + general, PULSE/resting-HR → pulse +
general, everything else → general) so the ingest path can drop only the
scopes a fresh reading dirties.
Pin the snapshot payload budget: a 2-year daily weigher (weight + daily
BP) and a six-type dense account both serialise under the 24 000-char
cap, proving the graded rewrite holds the line on the worst-case
accounts. The stub-skip and timeout-no-persist behaviour is covered by
the per-generator cache-read tests + the shared status-cache suite.
The seven per-card generators embedded the full ~360-day daily array
per metric plus correlation pair arrays, and never touched the
WEEK/MONTH/YEAR rollup tier. A dense daily weigher ran the weight
snapshot ~80-110 KB; a many-type account could push general-status past
100 K tokens.
Embed the shared graded series instead of the full daily array, cap the
embedded correlation/paired arrays to their most recent entries, skip
measurement types with no data in general-status, and apply a generic
24 000-char snapshot budget that sheds yearly -> weekly -> array-tails
-> recent until it fits. The applyPayloadBudget daily buckets stay
in-process to drive the latest-day focus, in-target gates, and
correlation pairing; only the compact shape reaches the prompt.
Measured on a 2-year daily account: weight-status ~15 KB, general-status
~12 KB across six dense types — both well under the cap.
Turn a metric's history into a small, bucketed, AI-ready shape: the
last ~21 days verbatim at daily granularity, the ~10 weeks before that
as ISO-week means, the ~12 months before that as calendar-month means,
and everything older as yearly buckets with a least-squares trend
slope. ~30-45 rows per metric regardless of history depth, never an
individual reading beyond the recent window.
A pure in-memory builder folds raw points; a rollup-backed builder
sources the monthly/yearly slices from the pre-aggregated MONTH/YEAR
tier (readBestGranularityRollups) and the recent/weekly slices from a
bounded ~90-day raw read, so the coarse rollup tier finally earns its
write amplification. The recent/weekly bucketing mirrors the coach's
timeline split, duplicated here so the coach contract stays put.
The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.
Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
Each insights category page now carries two additions placed beside the
chart and assessment:
- a target-range card (MetricTargetSummary) showing the numeric goal
band and the share of logged days within it, read from the shared
/api/insights/targets payload so the bands match the /targets editor
exactly. Blood pressure stitches the systolic and diastolic bands into
one S/D string. A discreet link routes to /targets to adjust the band.
- a question-mark explainer next to each heading (MetricExplainer in
SubPageShell) opening a static, layperson 'What is X?' read. Popover on
desktop, bottom-sheet on phones; keyboard- and touch-reachable with a
44px hit surface. Copy ships in all six locales.
The /targets page stays the canonical place to tune the bands.
2026-05-31 21:32:32 +02:00
709 changed files with 81333 additions and 10902 deletions
| BYOK AI fallback | open | resilience gap — no provider fallback is configured, so a single provider outage stalls assessment generation. |
| `MedicationAdministration` per-export cap tuning | open | the per-dose administration emission needs a sensible cap for high-volume histories. |
| SNOMED route / site upgrade | open | route-of-administration + injection-site could carry SNOMED codes rather than the current local enum. |
| German bfarm ATC URI behind a locale toggle | open | the export codes ATC against the WHO system; a German deployment may prefer the bfarm URI — gate behind a locale toggle. |
- [x] Refresh-token reuse-detection switches from "revoke all" to
per-device-token revocation (legacy null-deviceId tokens fall
back to user-wide revoke — safety hatch)
- [x] New route `GET /api/auth/me/devices` lists active devices
with last-seen, label, channels, isCurrent marker
- [x] New route `DELETE /api/auth/me/devices/[id]` revokes one device
(refresh + access tokens + Device row)
- [x] Alternate `DELETE /api/devices/[id]` for the iOS token-rotation
cleanup path (mirror of the auth/me variant)
- [x] Ownership-boundary 404 on cross-user attempts
- [FIXED commit pending] M (code-review M-1 / security Info-1 / product-lead M2 / senior-dev L2): conversation-summary.ts fold-loop decrypt not fault-isolated → wrapped in try/catch (flatMap skip). 4 reviewers flagged same spot.
- [FIXED] M (senior-dev): live/rollup tiebreak divergence for ranked types with only off-ladder sources → unified JS collapse + rollup all-time SQL on `source` ASC (matches canonicalMeasurementsFrom + route CTE). Unit test updated.
- [FIXED] L (security L-1): stale `/^[A-Z_]+$/` doc comment in source-rank-sql.ts → `/^[A-Z0-9_]+$/`.
## PENDING: read qa-simplifier.md + qa-i18n-deadcode.md when they land; reconcile any new Medium+.
## LAST 2 REVIEWERS (simplifier + i18n/knip) — reconciled
- [FIXED] HIGH (i18n/knip + simplifier S1): 3 net-new unused exports failing the enforcing knip Dead-code CI gate — SUMMARY_PROMPT_VERSION + SUMMARY_TARGET_CHARS (conversation-summary.ts) + FACTS_PROMPT_VERSION (facts.ts). Fix: deleted the 2 pure version constants (no consumer), and CONSUMED SUMMARY_TARGET_CHARS as a real safety cap on the stored summary length. Post-fix knip: 3 gone, exit clean. typecheck clean, 47 compute tests green.
- [DEFERRED — Low/perf, rationale] simplifier S2 / code-review L-2: the userPriorityJson load-once param is threaded by zero callers → fast-paths make 2-4 redundant loadUserSourcePriority lookups/request. Split rating (code-review Low, simplifier Medium); impact = sub-ms indexed PK reads, NOT correctness/security/safety; the param already exists for when it matters. Deferred to backlog to avoid ship-time scope creep across 3 loop sites.
- [DEFERRED — Low] simplifier: ExtractOpts.now dead field (facts.ts) — harmless forward-compat; stale measurement-read.ts docstring ("max count" vs source-name tiebreak) — cosmetic; residual hand-inlined canonicalMeasurementsFrom copy in summaries-slice — equivalent SQL. All Low, backlogged.
- i18n: CLEAN (zero new t() calls, no UI files, prompts server-composed). openapi:check IN SYNC.
## FINAL QA TALLY: 0 Critical / 0 High open / 0 Medium open. All Medium+ reconciled (decrypt isolation, tiebreak parity, knip unused exports). Lows deferred with rationale. SHIP-CLEARED.
- **Review and clear what the Coach remembers.** A new "What the Coach remembers" settings section lists the durable facts the assistant has learned about you, grouped by category, and lets you forget a single one or clear all of them. Encrypted at rest; available wherever the Coach is enabled.
- **More dashboard metrics you can pin.** Cardio recovery, six-minute walk distance, stair ascent/descent speed, breathing disturbances, wrist temperature, fall count, and walking steadiness can now be pinned to your Home layout.
### Security
- **Outbound request hardening.** The AI provider clients (when pointed at a custom base URL) and the optional IP-geo lookup now reject any address that resolves to a private or internal host, closing a server-side request-forgery / DNS-rebinding gap.
### Fixed
- A rare write race when two updates recompute the same day's rollup at once is now handled cleanly instead of surfacing a transient error.
- Analytics fast paths resolve your source-priority preference once per request instead of repeatedly.
Closes the three follow-ups left open by v1.11.0: cross-source vital de-duplication, the Coach's conversation memory, and durable personal facts.
### Added
- **Coach long-term memory.** The assistant now keeps a private, encrypted rolling summary of long conversations and remembers durable facts you have told it — standing preferences, goals, constraints, and conditions you have stated about yourself. Both are descriptive recall, never a diagnosis, and ride the same Coach setting; turning the Coach off stops all of it.
- **A facts surface to review and clear what the Coach has learned.** List your active facts, forget a single one, or forget everything (`GET` / `DELETE /api/insights/coach/facts`, `DELETE /api/insights/coach/facts/{id}`). Facts are stored encrypted at rest.
### Changed
- **Measurement rollups are now source-aware.** When two sources report the same standard vital on the same day (for example a resting heart rate from two devices), charts, summaries, and insights now show the higher-priority source's reading instead of a blend of the two. Cumulative metrics such as steps and energy continue to total per source. Your source-priority settings decide which reading wins.
### Notes
- The "latest" value shown for a vital with two competing sources now follows your source priority for the most recent day, so it matches the chart line. For some metrics this may surface a different device's reading than before.
- After clearing a Coach fact it can take up to a minute to disappear from the assistant's working context, in line with the existing snapshot refresh window.
- On first start after upgrading, each account's rollup cache is rebuilt once in the background; charts fall back to a live read until it converges.
## [1.11.0] — 2026-06-04 — WHOOP, a longitudinal coach, and a clinician-grade record
A multi-feature milestone across three fronts: a second connected provider, deeper Insights, and a shareable clinical record.
### Added
- **WHOOP integration.** Connect a WHOOP account with your own developer keys to bring its recovery, day- and workout-strain, sleep-performance, HRV (RMSSD), and energy readings into HealthLog alongside Apple Health and Withings — synced on a schedule and via signed webhooks, each value kept distinct by its source so it never overwrites a reading from another device.
- **A longitudinal Insights coach.** A weekly/monthly narrative summarises what changed over the period and the likely contributing factors (descriptive, never causal); the Coach now carries a rolling profile of your recent baselines and trends; and a short-horizon trajectory projection shows where a metric is heading with an honest, widening confidence band — shown only when there is enough history to mean something.
- **A clinician-grade health record.** A read-only FHIR REST API (`GET /api/fhir/*`) serves your data as standard FHIR R4 resources, and you can mint a scoped, time-limited, revocable share link that opens a clean read-only clinician view — no account needed. Wellness figures stay fenced off from the clinical ones with a plain "not a clinical assessment" note.
- **More resilient AI generation.** A durable provider-health record skips a known-bad credential instead of failing every run and surfaces an expired credential proactively, with a local model as a guaranteed fallback.
### Known limitations (planned follow-ups)
- When two sources supply the same standard vital (e.g. WHOOP and an Apple Watch both report resting heart rate), both readings may currently appear for a day until a source-aware aggregation update resolves them to your preferred source. WHOOP's own scores are unaffected; a note explains this on the WHOOP card.
- The coach reflects your rolling health profile and trajectory; full conversation-summary memory and saved personal facts are a later refinement.
- **The Strain reading now says which scale it used.** When the score is anchored to your own recent training load it reads "relative to your typical effort"; while there is too little history and it falls back to a general reference, it says so — so the framing always matches the number actually shown.
- **The six-minute-walk band notes its reference range.** The reference equation was established for ages 40–80; outside that range the percent-of-predicted is now flagged as an extrapolation.
## [1.10.3] — 2026-06-03 — Personalised strain, a daily signal card, deeper derived metrics
### Added
- **A "Today's signal" card.** The coincident-deviation read — which notices when several of your vitals sit off their personal baseline at once — now leads the Insights overview as a calm daily card instead of a flag buried in the grid. It shows an all-clear on a normal day, names the vitals to keep an eye on when a few drift, and always frames them as possible factors, never a cause or a diagnosis.
- **Derived bands for more of what your watch measures.** Overnight wrist temperature and stair-climbing and stair-descent pace each get a personal typical-range band, and your device's estimated six-minute-walk distance is placed against a published reference for your age, height, weight and sex. Each is shown with its method and its cited standard, and only once there is enough history. They appear under a new "Mobility & body" group.
- **Trailing trend sparklines** on the derived tiles, drawn from the readings the tile already uses.
### Changed
- **The Strain score is now anchored to your own training load.** It reads how hard a day was relative to your own recent training-day effort rather than a fixed population figure, so a genuinely hard day reads high even while you are building back up; with too little history it falls back to a general reference and says so.
- Age-banded reference norms now interpolate across bracket boundaries instead of stepping, and the sleep midpoint is computed in your own timezone.
- **The AI connection test now tells you what actually went wrong.** A failed test used to surface a cryptic parse error ("unexpected token") because the server answered with a gateway-level error page the settings screen could not read; worse, the test probed a different provider than the one your nightly insights are generated with, so it could report a problem with a fallback you do not even use. The test now always returns a readable, translated reason — rejected credentials, rate-limited, a provider server error, or unreachable — and it checks the same provider your insights run on, so the result is the truth about your setup.
- **Every insight metric page carries the same time-range controls.** The core-metric pages (weight, blood pressure, pulse, BMI, sleep) were missing the period selector that the other metric pages already had; they now match. Drill-down back-links are consistent and a "show all values" link returns you to the metric you came from.
- **The derived-metrics dashboard no longer collapses or jumps while loading.** It shows a placeholder row at the right height, keeps its heading hidden until there is something to show, and offers an inline retry if the data fails to load instead of silently vanishing.
- **More status colours meet contrast on the light theme** across the medication and admin surfaces.
### Changed
- **The intra-day retention drain is back on.** The storage optimisation behind the stress reading was switched off in 1.10.0.2 after a daily-summary collision kept the app cycling post-deploy. The fold now adopts the existing daily row deterministically and recovers cleanly if a concurrent write reaches the same slot first — verified against real data — and the start-up defer that protects the connection pool stays in place.
- **The batch-ingest and health-record export endpoints cap request-body size**, rejecting an oversized payload before parsing it.
### Added
- **A capability endpoint for API clients.**`GET /api/meta/capabilities` returns the server's live metric, tile, ingest and FHIR vocabularies alongside a contract version, so a client renders what it recognises and ignores what it does not — no more transcribing each release's new identifiers by hand.
- **Every medication card looks the same now, whatever the type.** Oral, injection, GLP-1, as-needed, cyclic and one-time medications previously drew their "next / last intake" line, dose accent, spacing and compliance bars slightly differently, so cards in the same row could sit at different heights with the action buttons misaligned. The cards now share one layout: the same intake line, a reserved-height compliance area, and the take/skip buttons pinned to the bottom — so a row of mixed medications lines up cleanly. The dose accent also meets contrast on the light theme.
## [1.10.0.2] — 2026-06-03 — Disable the intra-day retention drain
### Fixed
- **Stops the post-deploy restart loop for good.** The intra-day retention drain (a storage optimisation behind the new stress reading) folded older samples into a daily summary, but on real data that summary collided with an existing daily row and the drain failed on every run, which kept the app cycling after a deploy. The drain is now switched off until it is reworked and tested against real data; the nightly pass is skipped, nothing is queued at start-up, and any work already queued completes harmlessly. No user-facing feature is lost — the stress reading simply falls back to its sparse-data behaviour.
## [1.10.0.1] — 2026-06-03 — Defer the intra-day retention drain past startup
### Fixed
- **The app no longer turns unresponsive shortly after a deploy or restart.** The new intra-day retention drain ran its catch-up pass the moment the background worker started, at the same time as the database migration, the other start-up backfills, and the first health checks — all sharing one connection pool. On an instance with a lot of history this exhausted the pool, so the app stopped answering until it restarted, then repeated. The catch-up now waits until start-up has settled before it runs; it is unchanged otherwise and the nightly pass is unaffected.
- **Derived wellness metrics, each shown with how it is worked out.** From data you already sync, the Insights surface now derives a personal typical range for a vital, a cardio-fitness band, a vascular-age delta, an HRV (SDNN) balance read, body-mass index, a sleep score, a readiness index, and a coincident-deviation flag that notices when several vitals sit off their personal range at once. Every metric states its inputs, its method in plain language, and the published standard it is grounded in, with an honest read of how much data backs it — never a bare number. A metric appears only when there is enough history to mean something.
- **Recovery, Stress, and Strain scores, computed each night.** Recovery blends your resting-heart-rate, HRV, sleep and respiratory trends against your own baseline; Strain reads the heart-rate load of your workouts; Stress is an HRV-derived proxy and is labelled as exactly that — not a sensor-measured stress reading. Each is stored as a first-class value you can chart, and each opens to a breakdown of its contributors and its cited basis.
- **More of what your watch already measures.** Cardio recovery, overnight wrist temperature, fall count, six-minute-walk distance, stair ascent and descent speed, and the sleep breathing-disturbance index are now ingested and surfaced.
- **An awareness timeline for device-flagged events.** Irregular-rhythm, high- and low-heart-rate, walking-steadiness, and breathing-disturbance notifications your device produced are shown as a timeline — strictly as awareness of your own device's certified result, never re-assessed and never a HealthLog diagnosis, with a permanent disclaimer to that effect. The section stays hidden until there is something to show.
- **A per-workout heart-rate series**, so an indoor workout with no GPS still yields a training-load signal.
- **A wellness summary in the doctor report.** The derived scores and metrics appear in their own section, kept out of the clinical vitals table, labelled descriptive rather than clinical, and exported to FHIR as `survey`-category observations with the same note — so a clinician's system never mistakes them for a vital sign or a diagnosis.
### Changed
- **The health-record export carries richer medication codings.** A `MedicationAdministration` now adds a SNOMED route and injection-site coding alongside the existing plain text, a German-locale export can append the German BfArM ATC coding next to the unchanged WHO ATC entry, and the number of administrations an export can carry is raised and made configurable by the operator.
- **The Insights overview reads its metrics through a single request** instead of one per tile, so opening the page no longer fans out into a burst of concurrent reads.
- **Score-band colours meet contrast on the light theme**, and the device-notification copy — including its medical disclaimer — is translated across every supported language.
- **The medication cards look right again.** A recent alignment change pushed each card's action buttons to the very bottom of the cell, which left an empty gap between a shorter card's content and its buttons when it sat next to a taller card in the grid. The buttons now sit directly under the card's content again; the cards still share a height and the dose rows still line up, without the gap.
## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint
### Changed
- **The medication compliance endpoint is now in the public API contract.**`GET /api/medications/{id}/compliance` was missing from the OpenAPI document, so a client generating its types from the contract could mistake the separate cadence endpoint (a different shape) for it. The route is now documented with its exact response — the 7- and 30-day adherence summaries, the per-day compliance grid, and the two-row display block — with the field a client should read for the headline 30-day rate and how to draw the per-day history. No behaviour change; the handler was already correct.
## [1.9.1] — 2026-06-02 — No assessment warm on a page visit
### Fixed
- **Opening the Insights overview no longer regenerates every assessment.** A returning visit fired a one-per-session background warm that asked the provider to regenerate the comprehensive briefing and every category and metric assessment at once — a burst that could run for minutes and make the whole app feel unresponsive while it ran, recovering on its own once it finished. Assessments are kept fresh by the nightly pass, and each metric's text refreshes gently on its own when you open it; the "prepare assessments" button still regenerates everything on demand. A page visit now only reads the cached text.
- **A time range you can choose on the Insights pages.** Each metric now carries range pills (week, month, quarter, year) and shows how the period compares with the one before it — the change in your average, stated plainly with its direction — so a trend reads at a glance instead of by eye.
- **Deeper mood insights.** The mood page now surfaces your time-of-day pattern (when your mood tends to sit highest and lowest), a stability read, and correlation cards that line mood up against weight and blood pressure — each shown only when there are enough paired days to mean something.
- **Standard drug codes on a medication.** A medication can carry an ATC and an RxNorm code (entered, never guessed). These flow into the health-record export: the exported `MedicationStatement` now codes the drug with the WHO ATC system and RxNorm alongside the plain name, and the export adds a `MedicationAdministration` for every dose you actually took or skipped, so a clinician's system can read the record without relying on free text.
- **A deploy-verification script** (`scripts/assert-deploy.ts`) that checks a target reports the expected version after a release.
### Changed
- **The medication advanced-settings page is rebuilt.** Import, intake-import and export sit in their own group, the external-API endpoints are listed one by one with collapsible request examples, and the layout is tidied so the technical settings read clearly instead of crowding together.
- **Targets are edited inline where they belong.** The separate `/targets` page is retired — you set a metric's target range from the metric itself, with no detour to a standalone editor.
- **The health-record export emits insurer coverage from a bare insurance number**, not only when a full insurer organisation is present, and carries a top-level narrative summary.
- **Notification settings wording** is tightened for clarity.
### Fixed
- **The app stays responsive under load.** The database connection pool was sized far too small by default and could starve while a background insight warm and a foreground request competed, which showed up as the occasional "server not responding" that recovered on its own; the pool is now sized for real concurrency and tunable for larger self-hosts.
- **A background insight warm no longer blocks a page**, and a burst of Apple Health / Withings sync no longer triggers a storm of regenerations — the warm is decoupled and the invalidation is debounced.
- **The medication card rows stay aligned.** "Last dose" and "next dose" could break onto different heights and look misaligned; the rows now hold an equal height.
- **Glucose entered in mmol/L converts correctly** in the editor instead of being stored against the wrong unit.
- **The admin and global layout no longer shifts** when a scrollbar appears, by reserving the gutter.
- **The legacy status endpoints are documented** — six older `*-status` routes were missing from the API contract and are now included.
## [1.8.7.1] — 2026-06-02 — Assessments for every HealthKit metric, one-tap pre-generation
### Added
- **A plain-language assessment on every HealthKit metric page.** The HealthKit metric pages (resting heart rate, heart-rate variability, blood oxygen, glucose, body composition, gait, audio exposure, sleep, and the rest) showed charts but no written assessment, because the core metrics' bespoke per-metric prompts don't scale to thirty more. Each page now carries an assessment, generated from the metric's own normal range and the direction a healthy value moves, anchored on your individual baseline — the same treatment the core metrics already had. A metric with no data keeps its existing insufficient-data note.
- **One-tap pre-generation of every assessment.** A button on the Insights overview generates all assessments in the background so they are ready immediately the next time you open them. The overview also warms a cold cache on its own, and the nightly pass keeps everything fresh — so a page opens to its assessment instead of a wait.
### Changed
- **"Auswertungen" is now "KI-Auswertungen" / "AI Insights"** in the settings heading and the menu, so it's clear the section is AI-generated.
- **Targets and Sources are two separate settings pages** instead of one combined page, each with its own heading.
- **The back link sits above the heading** on a metric's detail page.
- **Dropdown fields have symmetric padding**, so the chevron no longer crowds the right edge.
- **The Insights layout accepts the full metric set**, so a saved layout (order and visibility) can include every metric page, not just the core ones.
### Fixed
- **Assessments generate again.** The assessment provider's accepted model identifiers had rotated, so generation failed and an assessment could sit on "preparing" indefinitely; the model ladder is updated and falls through to the current identifiers.
- **The Active Energy assessment loads.** Its page requested an assessment under a mismatched identifier and silently fell back to "no analysis yet"; the identifier is corrected and now type-checked, so the same class of mismatch is caught at build time.
- **The source-priority editor is fully translated** for Spanish, French, Italian and Polish — it was English-only after the settings split.
- **Insight assessments are instant again.** A category's assessment was being re-prepared on almost every visit: the constant Apple Health / Withings sync kept evicting the cached text, and a miss only ever deleted-and-waited. Assessments now serve the last good text immediately (stale-while-revalidate) and refresh in the background — "preparing" shows only when an assessment has genuinely never been generated. The background refresh is also debounced per metric, so a sync burst no longer triggers a storm of regenerations, and it warms only the active language (halving the work).
- **The dashboard tiles never truncate the value.** In a dense tile strip the number could clip or wrap mid-value (e.g. "130 mmHg" showing as "13"); the value now always renders in full and the unit yields the space instead.
- **GLP-1 medications show the drug-level-in-blood estimate by default.** The estimated concentration curve was hidden behind the Research-Mode opt-in and below the dose-strength curve; for GLP-1 medications it now shows by default, above the dose-strength curve, with the "educational estimate, not a measurement" disclaimer kept clearly attached.
- **The Trends captions read consistently.** Metrics without an advisor note previously rendered their caption in a different, plainer style that broke the row; every Trends caption now uses the same treatment.
### Changed
- **The mood assessment sits directly under the first chart** on the mood insights page.
- **Mood narrative takeaways above the charts** — a ranked feed of plain-language findings (the weekday your mood dips, the overall trend, the tags that lift or lower it, your in-target share, your logging streak, the weekend effect), now drawing on the structured tags as well as free-text ones.
- **A GLP-1 dose-strength curve** that plots the titration history (2.5 → 5 → 7.5 mg …) in place of the previous empty therapy-course block.
- **A FHIR `Coverage` resource in the health-record export**, with an optional insurer institution number (IKNR) captured on the profile — making the insurer machine-resolvable for German systems.
- **An optional `source` on the measurements batch endpoint**, so a client backfilling manually-entered history can tag it correctly instead of defaulting to Apple Health.
### Changed
- **Medication compliance returns to two rows for every medication.** The cadence-aware timeline is gone; the two windows now scale to how often a medication is due — a daily medication keeps 7-day and 30-day, a rarely-taken one steps up to longer windows (up to twelve months) so both rows stay meaningful and the layout stays steady.
- **The medication advanced settings are a single, calmer column** with a symmetric danger zone (matched delete-history / delete-medication actions), replacing the cramped two-column layout.
- **The medication wizard uses a jumpable dot stepper** with a "next step" hint; in edit mode every step is reachable in one tap.
- **Insights category headers are quieter.** The question-mark circle is gone (the metric definition stays inline beneath the heading), the measurement-diversity hint moved to a heading lightbulb with the explanation on hover, the assessment border is de-emphasised, and "Coach fragen" moved to the header as an icon.
- **Target ranges are now edited inline from Insights.** The Targets page is deprecated — it carries a notice, is removed from the menu, and will be retired in a future release; editing happens where you read the trend.
### Fixed
- **Every briefing-driven Trend card now shows its caption** — additive metrics no longer render a chart with empty space below it.
- **A long mood note no longer stretches the entries table** — it truncates with the full text on hover.
- **The mood in-target share is shown once**, not duplicated between the headline tile and the takeaway feed.
- **Medication compliance no longer counts days before a medication existed**, so a newly-added medication's expected-dose figures reflect its real age.
- **A target reference panel on every Insights category page.** Each metric now shows the target band, where the latest reading sits inside it, a plain-language status, the guideline source behind the range, the 30-day average and a seven-day consistency strip — the same reference the Targets page carries, now on the page where you read the trend. Blood glucose surfaces one panel per context (fasting, post-meal, random, bedtime).
- **A statistics strip per metric** — minimum, maximum, median and mean — plus a dedicated page that lists every underlying reading with edit and delete, reachable from each Insights category.
- **Injection-site tracking and rotation.** Opt-in per medication (injections only): record the site after a dose, see the history on a body map, and get a rotation suggestion. Sites can be restricted per medication, and an account-level exclusion always wins.
- **Structured mood tags and a per-entry note**, with a notes timeline so the context behind a mood is kept alongside the score.
- **A richer mood page** — calendar heatmap, score distribution, weekday pattern, and correlations with sleep and activity.
- **A measurement-diversity nudge** that suggests logging at other times or days when readings cluster, for a fuller picture.
### Changed
- **The medication compliance display adapts to how often a dose is due** — a percentage for frequent schedules, a dose-by-dose adherence timeline for sparse ones, so an every-few-weeks medication reads sensibly.
- **The Trends section now follows the daily briefing**, charting the metrics the briefing actually flags rather than a fixed set.
- **Tightened the Insights category pages and rebalanced the overview** for a denser, calmer layout.
- **Gait and mobility metrics from Apple Health are consolidated to a daily mean overnight**, while discrete clinical readings (weight, blood pressure, glucose, mood, pulse) stay raw.
- **The health-record FHIR export codes HealthKit-only metrics under a dedicated code system** instead of the LOINC namespace, for FHIR R4 conformance and alignment with the native client.
### Fixed
- **A rolling-interval medication now surfaces its first dose at the start date** — including when that date is already in the past — so the dashboard and reminders no longer skip the opening dose.
- **The medication wizard no longer shows each time-of-day suggestion twice.**
- **Deleting a medication no longer hangs on a spinner.**
- **Mood entries with tags save atomically** — the entry and its tags commit together, and create and update return the saved tags immediately rather than after a refresh.
- **Accessibility:** the injection-site picker has a visible focus state and screen-reader labels; mood tag chips and the status pill meet the contrast and minimum tap-target guidelines.
- **The medication card shows the correct next intake for every schedule.** Flexible "interval after the last dose" and one-time medications previously showed a next-intake derived from a simplified day-of-week rule that ignored the interval and the last logged dose — so a medication taken every few weeks proposed tomorrow and never moved after a dose was logged. Both the standard and the injection card now read the schedule engine's computed next-due, so the next intake is right on creation and advances the moment a dose is recorded.
- **Insights status polling is bounded.** When a model is configured but generation stalls, the per-category assessment card no longer polls indefinitely on an open page — it stops after a fixed ceiling and shows its static state.
- **Clean deploys no longer hit a missing settings column.** Four `app_settings` columns existed in the schema but had only ever been applied ad-hoc, so a migrations-only deploy could fail on first read; an idempotent migration now adds them (a no-op on instances that already have them).
### Changed
- **Logging a dose confirms itself.** Marking a medication taken or skipped now shows a brief confirmation, and the card's next-intake updates immediately.
- **Each Insights category explains itself inline.** The short metric definition now also appears as a caption under the category heading, alongside the existing question-mark tooltip.
- **Medication history shows where each dose was logged** — web, the iOS app, a reminder, or an import — as a small origin label on each entry.
## [1.8.3] — 2026-06-01 — Insights no longer block the interface
Opening an Insights category and switching between them could freeze the whole interface for several seconds while the per-category assessment was generated. The interface now stays responsive throughout.
### Fixed
- **Switching Insights categories no longer freezes the interface.** Each category's short assessment is now served from cache as an immediate, read-only response; when it is not yet ready the page shows a brief preparing state and the assessment is generated in the background, so navigation and taps stay responsive. The client request is capped, so a slow or unreachable model can never block the interface — the worst case is a preparing state, never a frozen screen.
- **The overnight pre-generation warms the assessment actually shown.** The nightly warm pass keyed its cache differently from what the page requests, so a returning visit often fell back to an on-demand generation; it now warms the languages the interface uses and runs even when the daily summary was already cached, so a returning visit lands on a ready assessment.
- **A stalled model no longer re-blocks every visit.** A generation timeout is briefly remembered so the next visits return immediately instead of retrying the slow path each time.
- **Streamed loading states.** Insights and the dashboard now render a skeleton while their first segment loads, rather than waiting on a blank transition.
## [1.8.2] — 2026-06-01 — One intake row per dose slot
A multi-time medication could end up with two entries for the same dose — one still open, one marked taken — for a dose the user had not taken. This release guarantees a single entry per scheduled dose.
### Fixed
- **A dose slot can no longer hold a duplicate entry.** Logging a dose now updates the slot's existing entry in place instead of inserting a second row, regardless of where the log came from, and the logged time is aligned to the scheduled dose rather than the exact moment of the tap. A twice-daily medication no longer shows the same time twice (once open, once taken), the daily count is no longer inflated, and the "due now" prompt is no longer suppressed for a dose that has not actually been taken.
- **A recorded dose is never silently un-recorded.** A background re-sync that replays an open dose onto a slot already marked taken is now ignored rather than clearing the taken mark, so an offline sync can never flip a taken dose back to missed.
- **Concurrent logs converge.** Two logs landing on the same dose at once resolve to one entry instead of failing or duplicating.
- **Existing duplicates are cleaned up on startup.** A one-time pass collapses any duplicate dose entries already stored, keeping the recorded dose and correcting the affected compliance figures.
A follow-up to the multi-time compliance work in 1.7.3, closing the two surfaces it did not reach.
### Fixed
- **Every scheduled time of a multi-time medication is now loggable** — a medication taken more than once a day on a single schedule with several times (e.g. 07:00 and 19:00) surfaced only its first dose in the today tile and the logging sheet, so the second dose could never be recorded and the day's compliance was counted against a single expected dose. The today-window projection now creates one pending dose per scheduled time, matching how reminders are issued.
- **The compliance heatmap classifies each dose against its own time** — a twice-daily dose logged in the evening was measured against the morning time and read as very late, painting the day amber even when both doses were taken on time. Each logged dose is now matched to its nearest scheduled time, so a day with every dose taken on time reads as on time. Single-time schedules are unaffected.
The per-metric Insights pages become the coherent heart of the app. The data-driven assessment — a short, honest reading of your own trend — now reliably reaches the page: it previously timed out and cached a generic placeholder for the rest of the day, so most users only ever saw the platitude. The generation budget is aligned with the provider, the placeholder is never cached, and the AI payload is compressed into a graded shape (recent days raw, then weekly, monthly, and yearly aggregates drawn from the rollup tier) so a heavy logger no longer ships thousands of raw points. Each metric's prompt is rewritten to a consistent house style — name the finding, place it against your own baseline, offer one concrete step; honest on poor values, never alarmist, never diagnostic. Target ranges move onto each category page beside the chart, every category gains a plain-language "what is this?" tooltip, and the assessment is warmed overnight so the page is a cache read instead of a live call. Route slugs and tile ids move to English (German tile ids stay accepted as aliases, so existing clients keep working).
### Added
- **Per-category target ranges** — each Insights category page shows its numeric target range and the share of recent days within target, beside the chart and the assessment, with a link to adjust the range. The dedicated targets page remains the editing surface.
- **Per-category explainer tooltips** — a question-mark affordance next to every category heading opens a short, plain-language definition of the metric, available offline in all six languages and reachable by keyboard and touch.
- **Nightly assessment pre-generation** — a budget-gated overnight job warms the per-metric assessment caches (and invalidates them when fresh measurements arrive), so opening a category page is a cache read rather than a synchronous model call.
- **Graded measurement compression for assessments** — a shared compressor folds a metric's history into recent (daily), weekly, monthly, and yearly buckets, drawing the monthly/yearly tiers from the rollup store, so the model receives a compact picture instead of the full daily series.
- **Naming decision record** — `docs/adr/0001-insights-naming-convention.md` documents the convention: localised UI, English internal keys and route slugs.
### Changed
- **Data-driven assessments reach the page reliably** — the per-metric assessment generation budget is aligned with the provider (was capped well below the provider floor), runs through the provider chain with fallback, and a transient timeout or error is treated as a passing miss rather than cached as a placeholder for the rest of the day.
- **Assessment wording** — every metric prompt follows one house style (name → place against own baseline → one actionable step; 2–4 sentences; honest, autonomy-supporting, non-diagnostic; says so plainly when the data is too thin). Blood pressure reads systolic and diastolic together; weight tracks the trend while BMI speaks to threshold bands; the mood assessment carries a calm support cue on a sustained low.
- **Insights route slugs are English** — `/insights/<german>` moves to `/insights/<english>` with permanent redirects from the old paths. The dashboard-layout tile ids are English canonically; the previous German ids stay accepted as aliases and are normalised on read and write, so a client that stored the old ids keeps working.
- **Per-metric assessment caches drop on measurement changes** — a fresh, edited, or deleted measurement invalidates the affected category's cached assessment so it regenerates rather than serving a stale reading.
## [1.7.3] — 2026-05-31 — Health-record PDF fixes, multi-time compliance
Two fixes to live behaviour.
### Fixed
- **Health-record export renders in the account language** — the PDF came out in English regardless of the in-app language because the export only consulted the browser's `Accept-Language`. The client now sends the active language and the server falls back to the language cookie before the browser header.
- **Health-record export layout** — charts now label their time axis (start and end date of the plotted window), modules are kept together instead of breaking across a page, a consistent bottom margin is held on every page, and a glyph-encoding issue that stretched a few lines (trend arrows and the `m²` unit) is resolved by sanitising to the font's supported characters.
- **Compliance counts every scheduled time on multi-time plans** — a medication taken twice a day on a weekday plan with two times but no recurrence rule reported 50% even when both doses were logged: the expected-dose count and the taken count expanded the schedule through two different code paths. Both now run through the one canonical recurrence engine, so the rate is correct for every schedule shape.
A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1.7.0 is now the default, so a cold dashboard paints its above-the-fold tiles together instead of letting the mood tile arrive ahead of the rest. The Coach's in-chat data-source rail now reads and writes the same persisted data clusters as the settings sheet — one source of truth that survives a reload, so what the panel shows always matches what reaches the model. The medication overview cards collapse their action row into a single overflow menu, and the detail page is pared back to the intake history under a read-only plan summary. The advanced-settings sheet widens into a two-column layout and offers a medications export beside the existing import.
@@ -18,11 +18,13 @@ If a draft commit message or release note would violate one of the three, rewrit
## Branch and release model
-`main` is release-only. Every commit on `main` is either a release commit (`chore(release): vX.Y.Z`), the matching tag push, or a maintenance commit (docs, gitignore, audit findings) that the maintainer chose to apply directly.
- Feature work targets the `develop` branch via topic branches (`fix/X`, `feat/X`), merged into `develop` through PRs with green CI.
-Releases merge `develop` → `main` (squash or merge-commit, document each), then a tag push (`v1.X.Y`) triggers the `docker-publish.yml` workflow.
-Hotfixes branch from `main`, merge back into both `main` and `develop`.
-Never force-push `main`. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
-**Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
-**A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
-**Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
Withings and Apple Health sync, multi-provider AI Insights you own, doctor-report PDF.
Withings, WHOOP, and Apple Health sync, multi-provider AI Insights you own, doctor-report PDF.
</p>
<p align="center">
@@ -36,9 +36,9 @@
## What it is
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings and WHOOP devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.10 — a derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), each computed from your own measurements and citing its inputs, on top of the v1.7 health-record export (PDF + FHIR R4), flexible medication schedules, and full HealthKit metric coverage. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
@@ -60,12 +60,26 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
| Open source | AGPL-3.0 | No | No | No | n/a |
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
| Blood pressure + glucose | First-class | BP only | Manual | No | Manual |
| Medication compliance | Cadence-aware | No | No | No | Manual |
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | n/a |
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a |
| Subscription required | No | For some metrics| No | Yes | No |
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
### Versus WHOOP / Oura / Fitbit ecosystems
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
- **It aggregates across providers instead of locking you to one.** Withings devices sync over OAuth2, an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, an Apple Watch, and a glucometer land on one timeline instead of three apps.
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
---
## Key Features
@@ -84,12 +98,16 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
**Clinician-grade FHIR API + shareable record** -- A read-only HL7 FHIR R4 REST API (`GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,MedicationAdministration,$everything}`) lets a clinician system pull your record in a standard shape, gated behind a dedicated `fhir:read` token scope. Alongside it, a shareable clinician record: create a scoped, time-limited share link, hand a clinician the read-only `/c/<token>` view, and revoke it when the visit is over. The two are not yet wired together — a share link does not expose the FHIR API (`capabilities.share.fhirApi=false`) — but each stands on its own.
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
@@ -197,6 +215,8 @@ HealthLog is designed for people who take data ownership seriously.
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
@@ -421,6 +441,24 @@ All mutations require authentication via session cookie. External ingest uses Be
| `GET` | `/api/fhir/$everything` | Bundle of every resource above |
Clinician share-link lifecycle — create a scoped, time-limited link, hand a clinician the read-only `/c/<token>` view, and revoke it after the visit. Share links do not expose the FHIR API (`capabilities.share.fhirApi=false`).
</details>
---
## Integrations
@@ -428,6 +466,7 @@ All mutations require authentication via session cookie. External ingest uses Be
@@ -488,9 +527,12 @@ For a single-process default the same container hosts both the web and worker (`
| ------------ | ----- |
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.8 – v1.10** (current) | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
Two known limitations carry forward from v1.11: dedup across two sources of the same standard vital is deferred (both rows persist, source priority decides display), and the coach's durable conversation-summary / remembered-facts layer is deferred — each answer reasons from your data afresh rather than from a running memory.
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
---
@@ -513,7 +555,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client
### What the phone unlocks
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app.
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
// Stored plaintext, matching `insurerName` — the IKNR is a public
// registry identifier, not a quasi-identifier like the KVNR, so it
// does not warrant the encrypted column treatment. Optional; an unset
// field collapses the Coverage payor identifier exactly like an
// unset `insurerName` collapses its name.
insurerIkNumber String? @map("insurer_ik_number")
@@map("users")
}
@@ -515,10 +560,90 @@ enum MeasurementType {
// and pass through `convertToDbUnit` as identity.
WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m.
WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s.
// v1.10.0 — additive HealthKit signals (WX-A)
// Seven previously-deferred quantity identifiers, wired end-to-end with
// the generic metric-status assessment treatment. Each rides the same
// server-side `convertToDbUnit` + plausibility-range path as the rest of
// the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`.
CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better).
WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot).
STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better.
STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better.
BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better.
// v1.10.0 — categorical events (WX-B)
// Device-flagged EVENT rows. These are NOT continuous measurements —
// each row records a discrete on-device notification the user's wearable
// produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one
// fired event); the device's verdict / severity lands in the
// `rhythmClassification` column (see the `RhythmClassification` enum and
// the CHECK constraint in migration 0105). HealthLog never re-derives or
// re-classifies the underlying signal — it only stores and surfaces the
// result the device's own certified algorithm already emitted.
IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis.
HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive.
LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold.
WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`.
BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis.
// v1.10.0 — computed scores (WX-C)
// Server-derived wellness scores (0–100, unit `score`). Each is minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source Measurement row — never ingested from a client. Only
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
// defined now so the later engines that compute them need no schema change.
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment. v1.11.0: WHOOP ships a device-native Recovery into this SAME type, distinguished by `source = WHOOP` vs `COMPUTED` — the two unique keys keep both rows; the cross-source picker decides display.
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0. WHOOP day-strain does NOT reuse this — it lands in DAY_STRAIN so the WHOOP-native value and HealthLog's TRIMP proxy never share a bucket.
HRV_RMSSD // ms — WHOOP `recovery.score.hrv_rmssd_milli`. The RMSSD HRV metric WHOOP reports; kept structurally distinct from the SDNN HEART_RATE_VARIABILITY (different estimator, not interchangeable).
DAY_STRAIN // score (0–21) — WHOOP `cycle.score.strain`. The day's cardiovascular load on WHOOP's 0–21 scale. Distinct from the COMPUTED STRAIN_SCORE (0–100 TRIMP proxy) so the two never share a bucket.
WORKOUT_STRAIN // score (0–21) — WHOOP `workout.score.strain`. Per-workout strain; preferentially stored in `Workout.metadata` (tied to the workout row) rather than as a free-floating Measurement, but the type exists for the rare detached case.
SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night.
ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips).
@@map("measurement_type")
}
/// v1.10.0 — categorical events (WX-B)
/// The device's own classification RESULT / severity for an EVENT row.
/// Non-NULL only for the categorical EVENT `MeasurementType`s
// Dedup at write time; cap at the full enum size. An empty array
// clears the exclusion.
globalExcludedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.meta({
description:
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
Loaded 100 of 709 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.