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>
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>
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>
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.
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 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.