The dashboard snapshot and the /api/mood/analytics route each carried a
hand-copied rollup read, coverage fallback, and daily summarize of the
same numbers. Two reads of one metric is the seam where the dashboard
tile and the insights mood sparkline can drift apart.
Extract buildMoodDailySeries as the single mood engine and route both
callers through it, so the same mood number reads identically on the
dashboard, the insights surface, and the iOS client.
A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.
Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
Add a Lab results section to the report aggregator (latest reading per
analyte over the window), a new labs section toggle (ON by default, the
same stance as BP and weight since the user recorded these to share with
a clinician), a PDF table with a quiet non-colour in/out-of-range marker,
and one laboratory Observation per result in the FHIR bundle carrying the
lab's reported referenceRange. Everything stays server-authoritative.
Record discrete biomarker readings (HbA1c, LDL, ferritin, TSH, …) with
their value, unit, reference range and sample date. The store pairs with
the annual blood-panel reminder, which until now had nowhere to record
the result it reminds the user to obtain.
The list/create/update/delete endpoints narrow userId from the session,
build the write data field-by-field, soft-delete via deletedAt, and
encrypt the optional note at rest. A neutral three-state reference-range
classifier (in-range / below / above / unknown) is computed
server-side so every surface renders the same calm verdict.
findLatestLabResultForAnalytes exposes the read point the blood-panel
reminder consults to mark itself satisfied, without coupling the two.
Register the /api/measurement-reminders surface in the OpenAPI table and
regenerate the committed spec so the iOS client consumes the
MeasurementReminderDTO contract from a single source.
Add the Vorsorge page and section: an upcoming-reminders list sorted by
server-computed next-due, with a create form (measurement-linked or
free-text, cadence, notify hour, location) and a per-card Erledigt and
delete action. Cards stay neutral-coloured regardless of due state — the
status reads through a discreet badge only, never an alarming tint. Copy
ships in all six locales.
Register the MEASUREMENT_REMINDER event type (default on at the channel
layer; the real gate is the per-reminder enabled flag) and add the
notificationPrefs.measurementReminder.clientManaged sub-object plus its
cron-side resolver, mirroring the medication and cycle client-managed
precedent. The event rides the generic APNs category path and stays
non-time-sensitive, so it delivers as a plain banner with no client
change.
Add the measurement-reminder cron, mirroring the mood-reminder shape: a
pure due-predicate (past nextDueAt AND inside the reminder's local
notify-hour) and a tick runner that dispatches through the existing
notification cascade. Auto-resolve runs in the cron, not on the ingest
hot path: a matching reading of the reminder's type advances
lastSatisfiedAt and skips the nudge (BP on the SYS sentinel; free-text
reminders resolve only on a manual satisfy). Dedup is the nextDueAt
advance past now, so no ledger table is needed. The queue is registered in
the all-queues list, scheduled, and bound to its worker; a registration
guard test pins that wiring.
Add the Vorsorge (measurement) reminder cadence layer: a thin adapter that
maps a reminder onto the canonical recurrence engine so a rolling
"every N days" or an RFC-5545 rrule cadence is driven by the same code the
medication next-due line uses. nextDueAt is computed and stored on the
server, so web and iPhone read identical numbers. Covers the DTO serializer
and the create/update validation schemas (exactly one of intervalDays or
rrule; BP auto-resolves on the SYS sentinel).
The add form already defaults the timestamp to now and accepts any earlier
instant — the plausibility bound and the picker's max both block the future —
but many users never notice they can change it. Add a one-line hint under the
date/time field so logging a past reading is discoverable, and pin the
manual-create contract with a guard asserting the single POST schema strips an
externalId (the stats:-prefix overwrite stays batch-scoped).
A self-hoster migrating from a spreadsheet or a meter export overwhelmingly
has CSV, which neither the JSON nor the Apple Health import covers. Add
`POST /api/import/csv` reusing the existing write loop and rollup re-fold.
The documented header is `type,value,unit,measuredAt[,glucoseContext,notes,
externalId]`, order-independent. measuredAt must carry an explicit ISO-8601
offset — a row without one is skipped rather than guessed — and is bounded by
the shared entry-instant validator. Glucose accepts mmol/L (converted to
canonical mg/dL) and weight accepts lb (converted to kg); any other
non-canonical unit is skipped, never mis-stored. An optional externalId
column makes a re-upload idempotent; without it a re-upload duplicates.
The response carries a per-row status envelope so a 2000-row file with three
bad rows imports 1997 and names the three. `?dryRun=1` validates and previews
the outcome without writing. The parse, validation, and unit conversion live
in a pure module so they are unit-tested without a database.
Settings -> Export & Import gains a third CSV card mirroring the JSON and
Apple Health cards, with upload, paste, preview, and a downloadable example.
The new route is documented in the OpenAPI contract.
Wire the goals step to actually keep its promise. The picker now posts
the chosen slugs on the step-2 submit; the step endpoint validates them
against the closed slug enum (422 on an unknown slug), persists them to
User.onboardingGoals field-by-field, and on completion seeds
dashboardWidgetsJson from the selection — promoting the goal-mapped
tiles to the top and forcing them visible.
The seed is one-time, gated on a null dashboardWidgetsJson via the
updateMany WHERE clause, so a user who already arranged tiles is never
clobbered. An empty or general-wellness-only selection leaves the
column untouched (default layout). The resulting layout is what both
the web dashboard and the iOS widgets contract read — no client
recompute. Skip posts an explicit empty array (deliberate no
preference).
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.
- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
Oura cards; the status read exposes hasOwnCredentials for the saved
placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
with unit tests; add the new strings across all six locales.
The WHOOP timeline reconstruction and the Withings END-stamp change the
stored measuredAt (and, for WHOOP, the externalId), so existing rows
keep the old broken shape until re-synced. An in-place update collides
with the (userId, type, measuredAt, source, sleepStage) unique index, so
the backfill deletes the affected SLEEP_DURATION rows for the source and
re-syncs from scratch; the re-sync re-folds the rollup tier in its tail.
Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP /
Fitbit backfills: discovery enqueues one job per (user, provider) where
sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs
and stamps the marker, and the pass is idempotent across reboots. The
queue is registered in allQueues, wired to a handler, and enqueued at
boot.
Withings sleep sync stamped each segment's measuredAt with its startdate,
but every reader treats measuredAt as the segment END and resolves the
span as start = end − duration. Each night was therefore rendered one
segment-length earlier than reality. Stamp the enddate so the span lands
at its true clock time.
The Google sleep payload carries a real per-stage segment series, but the
mapper summed each stage's segments into one row stamped on the stage's
last end. The hypnogram then reconstructed all of a stage's time as a
single block ending at the night's right edge — the same stacked-bar
artefact as WHOOP, despite the source carrying true timing.
Emit one row per segment with measuredAt at that segment's end and an
indexed externalId so the several segments of one stage stay distinct
under the dedup key. The timeline is measured (real onsets), so these
rows are not flagged reconstructed.
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.
Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.
The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
The mobile settings test asserted three integration pills; Polar, Oura and
Nightscout bring the list to six. Remove the unused Oura and Polar field-map
documentation constants and the unused advanced-indices interface the dead-code
gate flagged.
Surface WHOOP's native day strain to the coach on its own 0-21 scale, distinct
from the computed strain score, and instruct the model to prefer the device
number — the gold-standard signal a WHOOP user connected was previously invisible
to the coach. Document the glucose clinical block in the prompt so the model reads
time-in-range, GMI, estimated A1C and variability correctly and never asserts them
while still learning or off spot readings, and extend the non-English unit
vocabulary to cover glucose. Centralize the glucose panel window and run the
independent derived reads concurrently.
Add WHOOP-native DAY_STRAIN (0-21) to the Coach snapshot as a dedicated
dayStrain block, kept distinct from the computed STRAIN_SCORE (0-100) and
preferred native-over-computed, mirroring recovery resolution. Document the
glucose.clinical panel (TIR/GMI/eA1C/CV) plus the stillLearning and
isSpotEstimate gates in the system prompt, and the native dayStrain scale in
the derived paragraph. Extend the DE and FR/ES/IT/PL evidence-block unit lists
with mg/dL, mmol/L and min so a non-English coach can cite glucose and duration
values. Export GLUCOSE_PANEL_WINDOW_DAYS as the single source for the 30-day
clinical-panel window and import it across the analytics route, dashboard
snapshot, and Coach snapshot. Compute the derived metrics concurrently.
Bring the source step up to date — present the six shipped integrations instead
of a single vendor and a stale coming-soon badge, and signpost the import path
for existing history. Stop promising that the goals and source choices configure
the dashboard, which they do not yet; frame them honestly as helping the user
decide what to track. Set the calibrating expectation on the done screen so empty
tiles read as learning, not broken, and lead forward with connect and log CTAs.
Count any connected integration toward the setup checklist, not Withings alone.
Rewrite the source step to list the live integrations (Apple Health,
WHOOP, Oura, Polar, Nightscout, Fitbit) alongside Withings and manual
entry, each linking to where it is connected; drop the stale Apple
Health coming-soon badge. Add an import affordance for existing history.
Reword the goals and source copy so it no longer promises automatic
dashboard or reminder configuration that does not happen yet. Add a
calibration line to the done screen setting the expectation that some
insights sharpen over the first weeks, and lead the success screen
forward with connect-a-device and log-first-measurement actions.
Generalize the dashboard checklist data-source item so any connected
integration satisfies it, not Withings alone.
Derive the recovery source rank from the one priority ladder instead of a second
hand-kept map, re-point the WHOOP and Fitbit HTTP classifiers at the shared
status classifier, and lift the near-identical poll-cohort job handlers onto one
factory. Drop the Polar refresh-token column, which was only ever written null
since Polar tokens do not expire. Behaviour is unchanged across the recovery,
classifier, queue and route suites.
Polar and Oura response classifiers were byte-identical copies of the
status-driven verdict, the typed API error, and the invalid_grant probe.
Extract classifyHttpStatus, an IntegrationApiError base carrying the vendor
label, and a vendor-scoped isOAuthInvalidGrant into a shared module; Polar
and Oura now re-export under their existing names and types so call sites
and instanceof checks are unchanged.
Collapse the triplicated classifyXFailure mapping (Polar, Oura, Nightscout)
onto one toFailureKind helper. Behaviour is identical: same classifications,
same failure kinds, same error types.
Only the Nightscout sync queue was asserted in allQueues; the two OAuth-
wearable polls shipped alongside it carry the same v1.4.37 dead-queue risk.
Assert POLAR_SYNC_QUEUE and OURA_SYNC_QUEUE membership, cron, and handler
wiring so a future refactor can't silently drop one.
The entries URL carries the access token as ?token=<secret>; a SafeFetchError
embeds the full target URL in its message, and the sync feeds err.message into
recordSyncFailure, which the status endpoint returns as lastError. Strip token
and api-secret query values to =REDACTED before the message reaches the ledger,
so the secret can never surface in the status response.
buildExtras emits bpInTargetCount90 + bpInTargetSpanDays90, but the
registered extras schema (additionalProperties: false) omitted them, so a
strict iOS decoder would reject the dashboard snapshot. Declare both as
nullable integers to match the runtime shape and regenerate the spec.
Hard-coding isSpotEstimate: true mislabels a dense Nightscout CGM stream
(~288 readings/day) as a spot estimate and undersells the data in the
clinical panel and coach. Compute readings-per-day over the covered span
and treat the series as continuous below the spot caveat once it clears a
~hourly bar (24 readings/day); sparse spot fingersticks keep the caveat.
The panel only renders the spot-reading line when isSpotEstimate, and the
OpenAPI field relaxes from a literal-true to a boolean.
Move the low-stock trigger from a bare runway threshold to the larger of that
threshold and the reorder lead time plus one dose interval, so a weekly
medication is flagged before its last dose rather than after — early enough to
reorder. The change only moves the alert earlier; no one's threshold shrinks.
Surface concrete dates (runs out on, reorder by) and a running-low vs last-dose
state through the existing badge and channel, never the card colour. A reorder
lead time is configurable globally with a per-medication override, and the glp1
card flag now runs the same runway decision as the daily check instead of a
stale dose count.
The low-stock alert fired when the projected runway dropped below the
user threshold (default 7 days). For a sparse cadence — a weekly
injection's 7-day floor is barely one dose-interval — the warning landed
with about one dose left, too late to reorder before the supply ran out.
Add an optional per-medication reorder lead time plus a user-level
default (0–60 days, default 10) and widen the alert trigger to
max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning
lands before the last dose for any cadence. Because the trigger is a
max() over the user floor it never shrinks anyone's current threshold; a
daily med with no lead keeps its prior boundary.
The push body and the card notice surface concrete dates — supply runs
out around <date>, reorder by <date> — with a last-dose informational
state and the days/units fallback when supply data is thin. Retire the
stale fixed 4-dose count threshold on the GLP-1 details endpoint and
route its card lowStock flag through the same runway evaluation so the
card and the daily cron agree. Surface the lead default in the low-stock
settings card with progressive disclosure; a single medication can
override it.
Additive schema: nullable Medication.reorderLeadDays (migration 0161, no
backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI
regenerated.
Connect Polar and Oura through full OAuth, mirroring the existing wearable
house-pattern: a signed, expiring, provider-bound state doubles as an httpOnly
cookie, tokens are encrypted on the user, and an hourly job pulls sleep, heart
rate, respiratory and activity into canonical measurements under each source,
idempotently. Polar Nightly Recharge and Oura readiness feed the recovery
series, which now ranks WHOOP, then Oura, then Polar over the computed proxy.
Settings cards, localized copy and the credential env contract are wired for
both; the providers are optional and configured all-or-none.
Connect an Oura account over the Cloud v2 API: an authorization-code OAuth
flow (shared app from OURA_CLIENT_ID / OURA_CLIENT_SECRET) on the same signed
state machinery as Polar. Unlike Polar, Oura issues refresh tokens; the sync
refreshes reactively on a 401, rotating and re-encrypting both tokens, and a
403 stays a hard reauth rather than a refresh.
An hourly poll-cohort job maps daily readiness to RECOVERY_SCORE, sleep to
per-stage durations plus efficiency / HRV / resting heart rate / respiratory
rate, and daily activity to steps and active energy — all tagged source OURA,
idempotent on (userId, type, source, externalId). Readiness flows through the
canonical recovery ladder above the computed proxy. Settings gains a connect /
disconnect card. Tokens are never logged or returned; every egress goes
through safeFetch.
Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.
An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
Emit invite links as a /invite/<token> path the iOS app intercepts as a
universal link to start a prefilled registration, with a browser fallback that
redirects to the register flow once the token shape checks out. The path is a
thin, side-effect-free redirect — no lookup, no logging, no enumeration oracle
— and carries the same no-store, no-index, no-referrer hardening as the other
secret-bearing path since the token rides in the URL.
Surface the server-authoritative sleep-rhythm read in the coach snapshot
so a reply can reference cumulative sleep debt and the chronotype band the
Sleep page and dashboard already show. The values come from the same
assembler those surfaces use over a fixed trailing-42-day window, never a
recompute, so the coach can only quote what the page renders.
Hold the learning gate: the chronotype band and social jetlag are emitted
only once the signal is ready; a calibrating chronotype is surfaced as such
and never asserted as a band. Document the new fields and minute units in
the system prompt.
Encode the admin invite QR as a clean /invite/<hlv_token> universal-link
that the iOS app intercepts via the AASA wildcard matcher. Add the
matching web route as a thin, shape-validated redirect onto the existing
register flow, and open the path on the public allowlist so an
unauthenticated scanner reaches it. A malformed token segment is
indistinguishable from no invite — no database hit, no enumeration
oracle. The token is never logged.
Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.
Assemble a sleep-rhythm read on the canonical per-night reconstruction — the
same midpoints and asleep totals the sleep score uses — that reports a rolling
sleep debt against the age-based need and an MCTQ chronotype with its mid-sleep,
social jetlag and band. A shared assembler caps the chronotype window so the
dashboard and the dedicated read return identical values, and both withhold a
confident reading behind a learning state until enough nights accrue. The sleep
page gains a debt headline and a chronotype card with the deeper detail behind
an advanced disclosure; the sleep score is left untouched so debt reframes the
sufficiency gap without double-counting it.