The rollup tier rounds its bucket mean to two decimals and live SQL rounds the
full-day AVG to two decimals; rounding through different intermediate paths can
land one cent apart on a half-cent boundary, so the 5-decimal toBeCloseTo was a
data-dependent flake. Assert to within a few hundredths; min/max stay exact.
With measuredAt now stamped at the segment END, a night that Withings
re-aggregates between syncs shifts its enddate, so the measuredAt-keyed
lookup missed the prior row and the create then collided with the unique
externalId — leaving the night stuck at its stale value. Key the lookup on
the stable externalId and refresh value, measuredAt and stage on update.
Caught by the withings-sleep-sync integration test against real Postgres.
The generated 0162 migration typed the measurement_reminders.measurement_type
column as the Prisma enum name "MeasurementType" rather than its @@map'd
Postgres type "measurement_type", so a fresh migrate deploy failed with
'type "MeasurementType" does not exist'. Unit typecheck passed (the generated
client is unaffected); only a real-Postgres deploy surfaces it. Verified the
full 0001-0166 chain applies clean against Postgres 16.
The WHOOP and Fitbit OAuth callbacks redirect back with
`?whoop=connected|error` / `?fitbit=connected|error`, but the settings page
only read that param for Withings, Polar and Oura — a user returning from a
WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast.
Extend the generic return handler to all four OAuth providers, add the
matching connected/failed/error i18n keys across every locale, and invalidate
both the per-card key and the consolidated envelope on a successful return.
Polar and Oura already write the shared IntegrationKey ledger but each fired
its own `/api/<provider>/status` round-trip from the card, so the page issued
one consolidated request plus three per-card ones. Fold Polar and Oura into
`/api/integrations/status` and let the cards read a passed view-model instead
of fetching, retiring the extra round-trips. The per-provider status routes
stay for the iOS and test callers.
The Polar and Oura credential-DELETE handlers emit an auditLog entry and park
the integration ledger at disconnected; the WHOOP and Fitbit handlers cleared
the token row and BYO credentials but logged nothing. Credential teardown is a
sensitive op — add auditLog plus markDisconnected to both so the audit trail
and the ledger state stay uniform across every BYO-key integration.
Oura and Polar recovery rows are bucketed on the local day of their raw
measuredAt, on the assumption that both stamp the wake morning. Polar
always anchors at UTC midnight of the wake date and Oura falls back to the
same anchor when no real wake instant is present, so for a user in a far
negative UTC zone that anchor reads as the previous local evening and a
single night can split across two recovery days near the date line. A
correct fix needs an anchor-kind signal threaded through the recovery
read so the date-anchored sources read their wake day in UTC while a real
wake instant stays local — out of scope here. Document the assumption at
the bucketing site; positive-UTC users are unaffected.
The webhook shared secret lives only inside the AES-GCM-encrypted channel
config blob and the GET masks it, so no current path excerpts it into a
wide event. Add headervalue to the sensitive-key denylist as defence in
depth: if a future change ever routes a webhook body through the payload
diagnostic, the secret redacts instead of landing verbatim. The Live
Activity push token is already covered by the token pattern.
The VAPID regenerate overwrite guard used a native window.confirm for a
subscription-invalidating destructive action, where the rest of the
release uses the project's AlertDialog. Drive the overwrite confirm
through an AlertDialog (calm in-app dialog, destructive action variant)
and retry with force from its action.
The /labs header subtitle used truncate, clipping mid-word in longer
locales on a 375px viewport. Drop truncate so it wraps; the add button
stays shrink-0 and the row aligns to the top.
The doctor-report PDF computed SLEEP_DURATION min/avg/max over the raw
per-stage rows (a 40-minute DEEP block, a 12-minute AWAKE block), so its
sleep figure was per-stage-row minutes while every other surface — the
dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed —
shows the reconstructed per-night asleep total. The sleep-stamping work
rewrites exactly those raw rows, so the report could drift independently.
Route the report sleep value through the night reconstruction engine, the
same way the recovery score routes through the canonical recovery resolver
— one number, every surface. The user's source priority feeds the
reconstruction so the multi-source de-dup matches the dashboard.
The measurement-reminder tick loaded every enabled reminder across all
tenants every 15 minutes and gated due-ness in Node, so the shipped
measurement_reminders_user_id_next_due_at_idx went unused. Add a
nextDueAt bound (not null, lte now plus one tick of slack) to the query
so Postgres scans only rows that could plausibly fire. nextDueAt is
stamped at the notify-hour boundary, so anything due is already past;
non-recurring reminders carry a null nextDueAt and can never fire, which
the in-Node predicate already short-circuits. A reminder satisfied early
re-anchors once it crosses due, before any nudge, so dropping future rows
is safe. The in-Node hour gate stays the authoritative fire decision.
The email sender built its nodemailer transport without connection,
greeting, or socket timeouts, so nodemailer's defaults applied — up to a
10-minute socket timeout. A mail server that accepts the TCP connection
then stalls (firewalled relay, overloaded Postfix, blackholed smarthost)
could hold a dispatch worker for minutes per notification, while every
other egress in the cascade is bounded. Pin connection/greeting at 10s
and socket at 20s so a stall maps to the transient send outcome the SMTP
classifier already retries, matching the webhook sender's ethos.
The four Layout personalization editors (dashboard, insights, medications,
mood) are reached through the Layout & Personalization hub or a page-header
cog, and the left rail highlights the hub while the body shows the child.
The only return path was tapping the highlighted rail entry — no explicit
up-affordance, sharper on mobile where the rail collapses to a chip strip.
Add a shared back-to-hub link at the top of each child so the hub → child
→ hub loop reads clearly.
Also fix the stale doc-comment on the settings section route: the slug set
is 16 (12 nav-visible plus the four routable-but-hidden Layout children),
not eight.
Vorsorge (preventive-care) was a top-level tracking surface reachable only
three taps deep through Settings → Reminders, while peer surfaces (Labs,
Recovery) already had a top-level nav home. Add it to the one shared
destination model in the clinical spine, so it renders on the desktop
sidebar and the mobile More hub like every other feature, with a
nav.vorsorge key across all six locales.
Bug Report, Settings and Notifications were still a second hand-curated
list on each bar — the exact drift the one-model contract set out to kill,
just pushed down a level. Promote them to a shared NAV_UTILITY_DESTINATIONS
list both bars consume: the sidebar footer + avatar menu and the bottom-nav
More-hub tail now read from one source and can no longer disagree. The
More hub itself is now computed by mobileMoreHubDestinations() so the
headline invariant (hub == feature list minus primary slots, plus the
utility tail) is a tested model function rather than inline bar logic.
Add a sidebar utility-parity guard and a mobile More-hub invariant test.
The reminder list read isLoading but rendered nothing, so the page
showed a header over blank space while fetching.
- Loading now paints a card-shaped Skeleton stack via the shared
primitive.
- The no-data case routes through the shared EmptyState with an icon
and an add action, matching the lab empty.
- Reminder cards title themselves with CardHeader/CardTitle + CardAction
and the form card drops its pt-6 override, so the card token owns the
vertical rhythm and the double-pad goes away. The card stays neutral —
due state still reads only through the discreet badge.
The lab list shipped a second bespoke compact-tile shape and a
centred Loader2 that disagreed in size with the page spinner above it.
- Lab cards now title themselves with CardHeader/CardTitle and pin the
sparkline + delete control in CardAction, matching the canonical card
contract; the py-4 content override is dropped so the card token
drives spacing.
- Loading paints a tile-shaped Skeleton stack via the shared primitive
instead of the bespoke spinner, removing the size drift and the
layout pop-in.
Route the recovery and sleep-quality device-score surfaces through the
canonical card and loading primitives so they read as one family:
- DeviceScoreTile titles itself with CardHeader/CardTitle + CardAction
and drops its bespoke header row and padding override, so it matches
the sibling sleep-debt and chronotype cards on the unified
p-4 md:p-6 token.
- Add DeviceScoreTileSkeleton / DeviceScoreGridSkeleton, a shared
tile-shaped placeholder that mirrors the loaded layout. Recovery and
sleep-quality paint it while their analytics slice loads instead of
popping in.
- Recovery's no-data branch now renders the shared EmptyState with an
icon rather than a hand-rolled div; the sleep-quality sub-section
keeps its calm collapse-to-null once data lands.
- New insights.recovery.emptyTitle key across all six locales.
The JSON and CSV paste boxes now cap input at the 16 MB body ceiling the
routes enforce and show a live character count beneath each, so an over-long
paste is bounded with feedback instead of silently rejected at submit. The
CSV Preview button also renders the same spinner the Import button does while
a dry-run is in flight, with motion-reduce honoured, so the tap no longer
looks inert on a slow preview.
The JSON and CSV cards linked to the in-app docs with a raw anchor, so
tapping the schema link triggered a full-page reload that discarded an
in-progress paste or upload in the sibling card; route them through the
Next Link instead. The three import cards also dropped to a two-column grid
at md, orphaning the CSV card alone on a second row — add a three-column
track at xl so the set reads as one family.
The three import success check-icons hardcoded text-emerald-500, bypassing
the semantic text-success token every other surface uses. The token is
AA-tuned per theme (a darker green in light mode) where emerald-500 is a
single fixed value, so the import ticks sat off-tone in light mode and could
fall short of the AA bar the light tokens are tuned for. Swap to text-success
to match the integration-card family.
The downloadable CSV example pinned its header as an inline string literal
while csv-measurements.ts exported CSV_EXAMPLE_COLUMNS as the documented
single source of truth for that order — so the two could silently drift.
Derive the example header from the constant; the column order now has one
owner and the previously unused export is wired in.
Polar exposes only per-stage duration totals, so its mapper already
reconstructs a contiguous CORE-DEEP-REM order exactly like WHOOP and flags
each segment reconstructed. The night reader's honesty allow-list only held
WHOOP, so a Polar-won night was served with reconstructed:false — presenting
a synthetic stage order to web and iOS as if it were the device's measured
timing. Add POLAR to the allow-list so the layout is labelled approximate.
Polar's total_interruption_duration carried the night's awake time but was
dropped, so every Polar night read as fully consolidated (awakeMinutes:null,
awakenings:0) with overstated asleep-vs-in-bed efficiency. Lay it as a
leading AWAKE segment so the asleep stages partition the remaining window and
the reader surfaces real awake time.
WHOOP and Polar independently implemented the same contiguous-timeline walk.
Extract a shared reconstructContiguousSleepTimeline helper under src/lib/sleep
and call it from both; each mapper keeps only its field normalisation. Oura's
measured 5-min hypnogram is a different algorithm and is left untouched.
The annual-blood-panel auto-resolve that findLatestLabResultForAnalytes was
meant to feed never got wired into the Vorsorge reminder, so the export sits
with zero callers and the labAnalyteTrend query-key has no consumer (the
sparkline is prop-fed and there is no labs trend endpoint). Remove both so the
knip gate is clean; the Vorsorge lab read-back can come back with the wiring
that needs it.
Reminders used to be scattered across three places: medication reminders on
each med card, mood / low-stock / coach-nudge inside the Notifications channel
screen, and the Vorsorge reminders on their own page. The new Reminders
section is the single front door. It draws a clean line between the two
concepts — reminder categories (the what/when) and notification channels (the
how/where) — and links to each canonical editor, which keeps its own route and
deep link intact. Consolidation by linking, not a rewrite.
The section sits right after Notifications in the settings nav.
Each integration card now carries the same discreet "Setup guide" link to
its runbook under docs.healthlog.dev/integrations/<provider>, so a user who
is mid-setup always knows where to go. The docs host lives in one shared
constant and the affordance renders identically across all six providers
(WHOOP, Withings, Fitbit, Polar, Oura, Nightscout).
Document the new Generate keys button in notifications.md as the easiest
Web Push setup path, demoting the CLI to an alternative. Note that
clientManaged medication-reminder suppression is an iOS-app-only contract
so a PWA-only operator does not go hunting for a web toggle.
Add the VAPID env-var names and APNS_KEY_B64 to .env.production.example
(the loader and APNs sender read them, but the example omitted them),
both as commented placeholders, with a note that Web Push needs no Apple
account and that these vars are not in the compose whitelist.
Add a Backup and restore section to getting-started.md: a local pg_dump
one-liner and the load-bearing warning that the ENCRYPTION_KEYS /
ENCRYPTION_KEY must be backed up alongside the dump or the encrypted data
is unrecoverable, cross-linking the off-host backup and key-rotation
runbooks. Add troubleshooting entries for the compose env-var whitelist
and pull_policy: always.
Add a server-side VAPID keypair generator so a self-hoster no longer has
to run a shell command to enable Web Push. A new admin-only route
(POST /api/admin/settings/web-push-vapid/generate) calls web-push's
generateVAPIDKeys(), stores the private key encrypted at rest, seeds a
placeholder mailto: subject the operator can edit, and returns only the
public key. The route is cookie-only via requireAdmin(); a Bearer token
cannot reach it. The private key is never returned and never logged — the
audit detail carries a marker only.
The admin Web Push VAPID card gains a Generate keys button that fills the
public key and subject fields on success. When a keypair already exists
the route refuses with 409 and the card confirms before retrying with
force, warning that regenerating invalidates existing browser
subscriptions.
Add the webhook + email Settings copy and the admin notification-health
panel labels across all six locales. Add the SMTP_* operator vars to the
env manifest (all-or-none: HOST + PORT + FROM enable the channel,
USER/PASS optional), to .env.production.example, and to the docker-compose
environment whitelist so an operator's .env values actually reach the
container.
Surface the webhook + email cards on the notifications settings page, and
add an operator-wide delivery-health view the per-self diagnostic could
not give. A single groupBy(channel, result) over the trailing 24h of
push_attempts lets the operator answer "are anyone's pushes failing?"
across all users without DB shell, and a count of auto-disabled channels
(enabled=false with an automated disabledReason) flags a flapping upstream
that silently disabled a channel after five failures. The aggregate is
anonymous — it counts channel x result, never names a user. Admin-only by
construction (cookie auth, never Bearer); the window is clamped 1..168h.
The panel rides the existing admin Reminders section.
Alerts and reminders over email — the one channel every self-hoster
already has. The SMTP transport (host/port/auth/from) is operator-supplied
via SMTP_* env and loaded once per process like the APNs key; the only
per-user value is the recipient address, so SMTP credentials never enter a
user's encrypted channel blob. Absent SMTP env disables the channel
cleanly: the dispatcher skips it and the Settings card hides itself.
Bodies are plain text — HTML is stripped and routine reminders run through
the emoji-strip path; no markdown library is added. SMTP 5xx maps to a
hard reject (permanent — auto-disable); connection/4xx errors are
transient and ride the backoff schedule. Adds nodemailer (the standard,
well-audited mail transport) and its types.
A single WEBHOOK channel covers Gotify, Discord, Slack, Matrix, and Home
Assistant: the user supplies a public URL and an optional shared-secret
header, and the dispatcher POSTs a small plain-text JSON envelope. The
outbound goes through safeFetch with requirePublicHost so the SSRF floor
and the connect-time DNS-rebinding pin apply to the user-supplied host,
exactly as they do for ntfy. Responses classify 404/410/401/403 as hard
rejects (auto-disable) and 5xx/429/timeout as transient (backoff).
Settings card mirrors the ntfy card with a save + test-connection flow;
the header value is never returned by the GET and is preserved across a
save round-trip when left blank.
Add WEBHOOK and EMAIL to the channel set, the dispatcher cascade, the
per-event default policy, the push-attempt ledger union, and the admin
diagnostic config-presence check. Channels are string-typed, so no
migration is needed. The dispatcher routes each new type through its
sender with the same decrypt + parse + classify path the existing
channels use; the cascade orders webhook + email between the self-host
relays and the Web Push fallback.
Add the webhook + email channel config shapes and their settings-form
validation schemas, plus the query-key entries for the two new config
reads.
Cover the contract that matters for the vendor-native score tiles: a tile or
section appears only when its metric has stored readings. Asserts the empty
tile collapses to nothing, the sparse series swaps to the learning state, the
Sleep quality block renders only the metrics with data, and the Recovery page
shows its calm empty note with no data and groups + cross-links the score once
present.
Oura's overnight body-temperature deviation — a signed delta from the personal
baseline that tracks illness and cycle onset — was stored but never shown. Add
it as a data-gated tile beneath the chart on the metabolic skin-temperature
page, its most coherent home. It mounts only when the Oura series has readings,
so a non-Oura account renders the page unchanged.
WHOOP and Polar record day strain, workout strain, ANS charge, cardio load, and
the day's whole-cycle average / max heart rate and kilojoule energy expenditure
— all stored end-to-end with no render host. The composite recovery score had
only its anatomy detail at /insights/scores/recovery; the raw signals around it
appeared nowhere.
Add /insights/recovery as the home for them, grouped into recharge, strain &
load, and the day's heart & energy. Each block is data-gated, so an account
without a strap or ring lands on a calm empty note rather than a broken surface.
When a recovery score is present the page cross-links to its anatomy view rather
than duplicating the ring. Server-authoritative throughout.
Give the page a single nav home alongside Workouts in the unified destination
model so it is discoverable on both the sidebar and the mobile More hub.
WHOOP and Oura write sleep efficiency, performance, consistency, need, the
disturbance count, and the Oura sleep score every night; until now none of them
rendered anywhere. Add a Sleep quality block after the rhythm section that
surfaces all six through the device-score tile.
Each tile is data-gated, so a non-wearable account never sees an empty card and
the whole block collapses metric-by-metric down to nothing when no source
writes them. It reads the same analytics summaries slice the page already
fetched for its duration gate, so it costs no extra round-trip.
The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
charge, body-temperature deviation, …) is ingested end-to-end and lit in the
AI chart-token allowlist, but no page renders it. These scores sit outside the
metric-status registry by design — they are vendor-rolled-up values, not
signals the app re-derives — so they carry no assessment card.
DeviceScoreTile is the surface for them: a title, the latest reading, the
trailing-window mean, and a small sparkline, read straight from the stored
analytics summaries slice (server-authoritative, never recomputed). One neutral
card, never a status colour. When the series is still sparse the sparkline gives
way to the shared LearningGate so a two-night-old strap never paints a jagged
two-point line. The connected variant reads its own summary so it can drop onto
a page that already owns an analytics read.
Add the page-level copy in all six locales.
ANS charge, cardio load, the sleep score, and the body-temperature
deviation reached the MeasurementType enum without a branch in
getPRDirection, which left the exhaustive switch non-total — typecheck
fell through to an implicit undefined return and the drift guard failed.
These are derived / homeostatic composites with no goal axis, so they
join the other scores and temperature metrics on the null PR direction.
Personalizing how the app is laid out was split across four unrelated
settings sections — Dashboard, Insights, Medications, Mood — with no
shared framing, so the same concept (arrange, reorder, show/hide) read
as four disconnected places, plus the settings nav carried four separate
arrange entries.
Add one Layout & Personalization hub that presents the four editors as a
consistent set of linked cards and replaces the four scattered nav
entries with a single home. Each editor keeps its own route and mutation
flow — deep links, the page-header cogs, and the hub's links all still
resolve — and a Layout child route now highlights the hub in the nav.
Deleting a user's stored Oura BYO credentials drops a live connection (a
token minted against the deleted app is orphaned), but the credentials
DELETE route skipped the auditLog + markDisconnected the disconnect route
performs, leaving the integration ledger parked at its last state with no
audit trail. Mirror the disconnect route when a token was present.
Drop two unused classifier type aliases and unexport the internal
invalid-grant helper, and refresh the stale 'full OAuth from env' headers on
the connect route and the env-credentials helper to the v1.17.1 DB-first
framing. Add the missing route tests for the BYO surface this release
touched: credentials GET/PUT/DELETE (422 reject, encrypt-on-write, the
audit/ledger branch) and the callback CSRF / cross-user / nocreds / success
paths.
Widen the Oura sync from a thin slice to the signals the v2 API actually
exposes, all server-authoritative:
- Real measured sleep timeline. mapSleep now reads the per-stage
sleep_phase_5_min hypnogram and emits one timed SLEEP_DURATION row per
contiguous 5-minute run, each stamped at its own end instant from
bedtime_start. The per-night reader reconstructs the true clock-time
timeline (measured, not synthesised) instead of the degraded
all-on-bedtime_end shape. Falls back to the four stage totals when no
hypnogram is present.
- daily_spo2 → OXYGEN_SATURATION and daily_sleep → SLEEP_SCORE, filling
two ladder slots that nothing wrote before.
- daily_readiness temperature_deviation → BODY_TEMPERATURE_DEVIATION, and
the eight readiness contributors are parsed (no longer dropped at the
JSON boundary) for the Insights surface.
- daily_activity equivalent_walking_distance → WALKING_RUNNING_DISTANCE,
so an Oura-only day no longer contributes zero distance.
Every sleep row now carries a record-scoped externalId
(sleep:<record-id>:<tag>) so a nap and the main sleep on one day stay
distinct instead of overwriting each other on a shared day key.
Two additive measurement_type values for the Oura coverage completion:
- SLEEP_SCORE — the headline 0-100 sleep-quality score (unit score),
kept apart from the WHOOP sleep sub-scores so two vendors' headline
numbers never share a bucket.
- BODY_TEMPERATURE_DEVIATION — a signed nightly body-temperature offset
from the personal baseline (celsius). It is a deviation, not an
absolute reading, so it cannot share the body / skin / wrist
temperature buckets.
Migration 0165 extends the enum (additive, no backfill). Register both in
the validation enum, units, plausibility ranges, category overlay, list-UI
label/icon/colour maps, chart-token allowlist, and the PR-direction switch
(both resolve to no record direction). Both are descriptive signals, not
clinical vitals, so they stay out of the doctor-report PDF. Localise the
two list labels across all six locales and regenerate the OpenAPI contract.
classifyPolarError now lifts a 400 carrying invalid_grant on the token
endpoint from persistent to reauth_required, mirroring Oura via the shared
isOAuthInvalidGrant helper. A stale or replayed authorization code at connect
time now surfaces the reconnect prompt instead of silently parking after 24 h.
The BYO-credentials DELETE now audit-logs and parks the integration ledger at
disconnected for parity with the dedicated disconnect route, and the PUT
audit-logs the credential write. Refresh the connect-route comment that still
described Polar as env-only OAuth; it has resolved BYO-first since v1.17.1.
Add the invalid_grant classification test plus the previously-missing
callback (happy path, CSRF mismatch, cross-user, off-spec token), connect, and
credentials route tests.
Polar read three collections off the wire and dropped several useful signals
at the mapper. Close the gaps, keeping every value server-authoritative and
canonical-unit:
- Sleep now follows the END-instant convention the canonical night-grouper
depends on. mapSleep reconstructs an ordered, contiguous CORE/DEEP/REM
timeline from the parsed sleep_start_time / sleep_end_time, stamping each
segment at its own end instant with an indexed externalId, plus an IN_BED
envelope and the score at the sleep end. This restores the hypnogram (the
stages no longer collapse onto one midnight-UTC instant) and fixes wake-day
mis-attribution for negative-UTC users. Records without a usable window fall
back to the prior midnight anchor. Synthetic ordering is flagged
reconstructed.
- ans_charge maps to ANS_CHARGE (through-as-is; a baseline-relative deviation
that can run negative).
- distance_from_steps maps to WALKING_RUNNING_DISTANCE (metres), filling the
Polar distance-ladder slot that previously had no producer.
- New fetchers + mappers for Training Load Pro (cardio_load -> CARDIO_LOAD)
and the Elixir SpO2 collection (-> OXYGEN_SATURATION), with Polar added to
the SpO2 source ladder below the established straps.
No strap/profile weight is fetched; egress stays on safeFetch.
Polar Nightly Recharge and Training Load Pro carry two native signals with
no existing home: the HRV-based autonomic-nervous-system charge (distinct
from the 1-6 recovery band that maps to RECOVERY_SCORE) and the device-native
cardio-load strain figure (distinct from WHOOP DAY_STRAIN and the COMPUTED
STRAIN_SCORE). Add both to the canonical enum (migration 0166) and wire them
through the validation enum, canonical units, plausibility ranges, category
cluster, chart-token allowlist, PR-direction resolver, and the measurement-
list label/icon/colour maps, with labels across all six locales. Both are
device-derived composites, so they read no PR direction and stay out of the
clinical vitals PDF, matching the WHOOP-native score classes.
Regenerate the OpenAPI contract for the two new enum members.
The Coach is reachable from seven scattered entry points — a floating
button, the hero CTA, suggested-prompt chips, every metric empty state,
a per-metric icon, the conversations drawer — but it had no entry in the
nav. Power users found it instantly; a new user could miss the
differentiator entirely behind a 56px floating circle.
Add the Coach to the shared destination model as one labeled, primary
home, consistent with every other destination across desktop and mobile.
The existing entry points stay; this adds the front door they lacked.
The desktop sidebar and the mobile bottom-nav each hand-curated their
own destination list, and the two had drifted: the sidebar listed
Measurements and Mood inline but hid Workouts; the bottom bar buried
Measurements and Mood in More but promoted Workouts. A user who learned
the product on one platform re-learned it on the other.
Extract one ordered destination model both bars consume. The sidebar
renders every entry in order; the bottom bar keeps its 5-slot shape and
derives its More hub from the same list, so the two surfaces tell one
story. Workouts is now first-class on both. A shared active-resolver
prefers the most-specific sibling so a sub-route no longer lights up its
parent.
Add an optional hex liveActivityPushToken to device registration. The iOS
client registers the current per-Activity ActivityKit push token so the
server can address a Live Activity update or end push on a
medication-intake mutation, and sends null to clear it when no Activity is
running. Following the medicationDelivery override convention, the field
is only touched when present, so an ordinary re-register keeps the prior
value. Regenerate the OpenAPI contract.
Wire the silent intake-sync push into both intake writers. The single
route fires once for the affected slot after the row is persisted; the
bulk route collects the distinct (medicationId, scheduledFor) slots whose
state actually changed and fires one push per slot, so a 500-row backfill
wakes a device per affected slot rather than per row. Pending echoes,
duplicates, and no-downgrade no-ops change nothing visible and are
excluded.
Both pass the originating device through the X-Device-Id header so the
device that recorded the dose is skipped. The dispatch is best-effort and
fire-and-forget: the canonical rows are already written, so a push miss
never affects the response.
Introduce the MEDICATION_INTAKE_SYNC event and the server-side dispatcher
that wakes a user's other iOS devices after a dose is logged on one of
them, so a running Live Activity or Home-Screen widget reconciles against
the canonical server state.
The dispatch is APNs-only by construction: it sends a silent
apns-push-type: background push (content-available, no alert) carrying
the affected medicationId + scheduledFor, and never fans out to Telegram,
ntfy, or Web Push, which have no silent-sync transport. A new raw APNs
send helper carries the background and liveactivity push types without
the alert/sound/badge enrichment the alert path injects. Devices APNs
reports as permanently dead are reaped; the originating device is
excluded from the fan-out via its registered device token.
When a device has a stored Live Activity push token, an additional
liveactivity end push goes to the per-Activity topic so the lock-screen
Activity ends immediately rather than waiting for the next background
wake.
Glucose, sleep debt, chronotype, the trajectory forecast, and the
composite-score anatomy each hand-rolled the warm 'still learning your X'
paragraph — five copies with different markup, markers, and no shared
accessibility contract. The calm voice held only because each author
copied the tone by hand.
Add a LearningGate primitive that owns the presentation and behaviour of
a learning state (the muted body, an optional caveat line, the learning
marker, and the polite live region) and repoint all five surfaces to it.
Each keeps its own card chrome and its metric-specific copy; only the
gate's look and behaviour are now shared, so they can no longer drift.
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 /api/measurement-reminders (list, create) and /{id} (get, patch,
delete) plus /{id}/satisfy. Every route wraps in apiHandler, parses with
Zod, narrows userId from the session, and builds the data object
field-by-field. Create and patch recompute the server-authoritative
nextDueAt; satisfy stamps lastSatisfiedAt and re-anchors the next-due.
Delete soft-deletes for tombstone parity.
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.
The generic JSON import validated measurement `measuredAt` with a bare
`z.string().datetime()` — no ceiling — so a crafted payload could forward-date
a reading, the gap the single-POST plausibility bound already closed. Route
the measurement and mood instant fields through the shared
`validateEntryInstant` (no future beyond a 5-minute skew, no instant before
1900).
Measurement rows were also always created with a NULL externalId, which is
distinct in the `(userId, type, source, externalId)` unique key, so a
re-import duplicated the whole set. Give measurements the same optional
externalId upsert mood already has: present -> idempotent upsert, absent ->
first-write-wins create.
A collapsible "About your health" card on the baseline step captures
the genuinely-new baseline the app can act on — chronic conditions and
allergies — without re-asking the height/sex/birthdate already gathered
above. Both write through the existing encrypted self-context path
(PUT /api/coach/about-me), so there is no new model and no new
contract; the form preserves any existing free-text about-me on save
and only sends a field the user actually typed.
The card is collapsed by default with a clear optional framing, so the
step stays clear and the progress bar keeps its five dots; every field
is editable later in Settings. Copy is warm and medically grounded —
context, not a clinical form — with keys across all six locales. Also
re-truths the goals body copy now that the selection genuinely surfaces
on the dashboard.
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).
The goals step let the user toggle goal slugs but both Skip and Next
discarded the selection — the picker was decorative. Add the
onboarding_goals text array the earlier dead code already referenced so
the selection can persist and seed dashboard tiles + reminder
suggestions. Not a clinical target store; targets stay fully derived
from height/age/gender in /api/insights/targets. Empty array = no
preference; purely-additive migration with an empty-array default, no
backfill.
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.
Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and
non-destructive.
- users: Polar and Oura BYO-app client id/secret, encrypted at app level and
stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1.
Each self-hoster registers their own Polar AccessLink / Oura Cloud app and
pastes the client id/secret into Settings before the OAuth connect flow.
- devices: live_activity_push_token for the iOS Live Activity update/end APNs
channel. The client owns the ActivityKit lifecycle; the server only stores
the most recent push token so the worker can address the update.
- measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a
rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives
the server-authoritative next_due_at. The completion target is an optional
measurement_type (auto-resolved when a matching reading lands) or a free-text
label that resolves on a manual check-off. Soft-deleted.
- lab_results: minimal structured lab store — one analyte per row, optional
panel grouping and reference range, optional encrypted note — so a returned
blood panel has somewhere to land alongside the Vorsorge reminder.
No enum change: the Polar/Oura/Nightscout measurement sources already landed in
migration 0160.
When both the sleep-debt and chronotype cards carry a settled readout, render
them two-up on large screens instead of stacked; they are compact and read
better paired. A lone data-bearing card — or either card in its learning /
partial state — keeps full width so it never leaves a half-width orphan. The
grid collapses to one column below the lg breakpoint and uses items-start so a
toggled-open chronotype card does not stretch its neighbour.
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.
Add the blood-glucose clinical panel with Nightscout, sleep debt and chronotype,
Polar and Oura over OAuth, reorder-aware medication supply and an invite
deep-link. Every new metric is computed once on the server and read the same way
on the dashboard, the coach, the doctor-report and the companion app, each behind
a still-learning gate. The coach now reasons from the new signals, mood and cycle
entry is one sectioned sheet, and previously-hidden wearable data surfaces.
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.
Light up the HRV tab when a device writes RMSSD instead of SDNN, so an Oura,
Polar or WHOOP user finally sees the nightly HRV the app already stored — the
chart swaps to the RMSSD series with a measure label rather than silently
merging the two measures. Add Oura and Polar to the steps, active-energy and
distance source ladders below the phone aggregate, and lift the optical-HR
wearables above a hand-typed pulse so the canonical reading is the device's.
Give the Polar, Oura and Nightscout cards the same parked-state resume banner
and test-connection button the other integrations carry, distinct brand icons,
a connected-state link to where their data appears, and the correct status-key
invalidation so the pill refreshes after a disconnect. Fix the sleep empty-state
link that pointed at a non-existent settings slug.
Update prettier, undici, lucide-react, react/react-dom (patch within the pinned
19.2 line) and zod-openapi to 6 (the OpenAPI output is byte-identical), plus the
docker/metadata and dependabot/fetch-metadata actions.
The v6 major leaves the createDocument API and the .meta() registry
annotations unchanged for this codebase; the generated
docs/api/openapi.yaml stays byte-identical and openapi:check passes
with no registry adjustment.
Lift the byte-identical Polar and Oura response classifiers onto one shared
classifyHttpStatus over the integration classification union, give the vendor
API errors a common base carrying the vendor label, and funnel the duplicated
failure-kind adapters through a single toFailureKind. Behaviour is unchanged —
the per-vendor names, signatures and the Oura invalid-grant lift are preserved.
Drop the bespoke vertical-rhythm override on the sleep-debt and chronotype
cards so they inherit the standard card spacing the glucose panel uses, give the
sleep-rhythm section a quiet error notice instead of an endless skeleton on a
failed read, and move the card accents onto the semantic info token.
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.
Drop the compact gap/padding override on the sleep-debt and chronotype
cards so they inherit the standard Card rhythm, matching the glucose
panel. Switch the card icons to the semantic text-info token. Branch
SleepRhythmSection on a query error to a quiet inline notice instead of
an endless skeleton, keeping loading and settled states distinct.
Every other briefing destination links to its dedicated insight sub-page; the
step row alone pointed at the generic readings view. Route it to /insights/steps
so the briefing, the tab strip and the dashboard all open the same step page.
Derive the glucose spot-estimate flag from reading density so a continuous
stream is no longer mislabeled a spot estimate, redact the Nightscout token
from a recorded sync error, rate-limit the Polar and Oura disconnect routes to
match the others, declare the two blood-pressure window fields the dashboard
snapshot already emits, make the reorder-lead-days migration re-runnable, and
guard the Polar and Oura sync queues against a silent drop.
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.
Both disconnect routes ran unbounded, unlike nightscout/disconnect and the
connect routes. Add the same per-user 20/60s checkRateLimit guard after
requireAuth so the surface returns 429 on bucket exhaustion.
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.
Two daily-use fixes: the last-night hypnogram bar collapsed to its breakdown
for nights whose stages share an end instant, and the dashboard kept prompting
for a dose until a full reload after it was recorded. No breaking changes.
The intake write invalidates the dashboard snapshot, but the default active
refetch type only refetches mounted queries — and the snapshot is unmounted
while the user is on the medication card or detail page. On navigating back it
remounts under refetchOnMount:false, so the pre-write cache was served and the
due prompt lingered until a hard reload. Force the inactive snapshot to refetch
after every intake write so the dashboard clears as soon as the dose is taken.
The staged bar is gated on the night carrying more than one distinct stage
boundary. The guard read the segment END instants, so an ordinary multi-stage
night whose segments share a right edge — the shape a summary-derived writer
reconstructs to — collapsed to the breakdown footer with no bar. Gate on the
distinct START instants instead, so a night with timed stages keeps its
timeline and only a session recorded on a single instant falls back to the
breakdown. Covers the chart-vs-breakdown branch the test suite did not.
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.
Add a sleep-rhythm block to the coach snapshot, built from the same assembler
the sleep page and the dashboard use over a pinned window, so the coach reasons
from the same sleep-debt and chronotype values the rest of the app shows rather
than a second computation. The prompt documents the new fields and keeps the
learning-gate honesty — a chronotype band is offered only once it is calibrated.
Extend the /c/ edge defence (no-store, noindex, no-referrer) to the
/invite/<hlv_token> surface — the invite secret rides in the path, so no
CDN may cache it and no crawler may index 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.
Add a Nightscout integration that pulls sensor glucose readings from a user's
own Nightscout instance and stores them as immutable canonical mg/dL blood
glucose, giving the clinical glucose panel the continuous density it needs.
Egress runs through the safe-fetch wrapper with the public-host pin on by
default and off only behind an explicit private-network opt-in, so a home or
LAN instance works without weakening the floor for everyone else. Readings are
first-write-wins by Nightscout entry id, an hourly job keeps recent history in
sync, and a settings card carries the URL, token and the private-network toggle.
Make computeSleepRhythmFromNights own the chronotype look-back so the dashboard
summary (a year of nights) and GET /api/sleep/rhythm (six weeks) return the
same band, mid-sleep, and social-jetlag values rather than averaging different
samples. Derive the free/work day-type from the local wake-day digits so the
classification holds in far-east timezones, and read the shared sleep-rhythm
DTO type into the client hook instead of a hand-mirrored copy.
Route the sync write through mapSgvEntryToMeasurement so the
entry-to-measurement shape has one source of truth instead of an
inline copy plus an unused parallel mapper that could drift.
Pull continuous glucose readings from a self-hosted Nightscout instance
and store them as immutable BLOOD_GLUCOSE mg/dL measurements
(source=NIGHTSCOUT), giving the glucose panel real CGM density.
- client over safeFetch: public-host pin by default, relaxed only on the
per-user private-network opt-in; redirect pinned manual + timeout
- credentials encrypted at rest on User; URL is the connect marker
- sync maps SGV entries first-write-wins on (userId,type,source,externalId)
- connect/disconnect/status routes: apiHandler + requireAuth + rate limit;
connect validates by a live test fetch with clear errors
- hourly poll-cohort job registered in the all-queues registry
- settings card with URL, token, and the private-network toggle
- i18n across all six locales
Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.
Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
Redesign both add flows as one sheet that is fast by default and deep on
demand: the mood face and a quick row of recently used tags stay open, the
period buttons and flow stay open for cycle, and everything else moves into
collapsible sections that each show a count of what is set inside them. A
shared SheetSection primitive carries the collapse, the summary slot and the
reduced-motion behaviour. Tag, factor and day-log logic is moved, not
rewritten, and the submit path is unchanged.
Introduce the schema foundation the glucose and wearable integrations build on:
the NIGHTSCOUT, POLAR and OURA measurement sources, encrypted credential columns
for each (Nightscout carries a base URL, an API token and a private-host opt-in
flag; Polar and Oura carry OAuth tokens), and their integration keys. The
recovery source ladder now ranks WHOOP, then Oura, then Polar over the computed
proxy. Additive migration only — new enum values and nullable columns, no
backfill. Nightscout is glucose-only and stays off the wearable priority ladders.
Extend MeasurementSource with NIGHTSCOUT, POLAR, OURA and add the
per-user encrypted credential columns each source needs: a Nightscout
base URL + token plus a private-host opt-in flag, and Polar/Oura OAuth
tokens mirroring the WHOOP columns. Migration 0160 is additive only —
new enum values, nullable columns, one boolean default false.
Register the three sources across the read enum, the integration-status
key set, and the source-priority ladders.
Compose two pure, server-authoritative modules on the canonical per-night
reconstruction rather than a second pass: a rolling sleep-debt that sums each
night's deficit against the existing sleep need, drains as nights age out, and
never mints negative debt; and an MCTQ chronotype from free-day mid-sleep with
the sleep-debt correction and the social-jetlag gap, classified into a band
through the shared estimator. Both withhold a confident reading behind a
learning gate until enough nights accrue.
Pure, server-authoritative glucose analytics over canonical mg/dL readings:
time-in-range on the Battelino consensus bands, the glucose management
indicator and estimated A1C from mean glucose, and the coefficient of
variation with the Monnier instability threshold, pinned to a configurable
~14-day window. A learning gate withholds a clinical assertion until enough
readings over a long enough span accrue, and every result is marked a
spot-reading estimate rather than a CGM profile, so a client renders the same
honest figure the rest of the app shows.
Read sleep, recovery and blood glucose from a single source on every surface —
dashboard, coach, doctor-report, CSV and FHIR exports, and the companion app —
so the displayed figure agrees wherever it appears. Sleep nights reconstruct
once with source de-duplication; WHOOP-native recovery is canonical and resolves
to one row per night; glucose converts to the user's unit at the point it is
served. Hardens the consent receipt path and deepens WHOOP sync with per-resource
cursors, targeted refresh and full backfill. Several displayed numbers become
correct-but-different — flagged in the changelog.
Pass the report's timezone into the canonical recovery resolution so the PDF
buckets a WHOOP-native row and its computed proxy onto the same physiological
night — the proxy is stamped the day that ended while WHOOP stamps the wake
morning — instead of a raw calendar day. Aligns the printed recovery summary
with the tile, the coach, and the companion feed.
WHOOP stamps a recovery on the wake morning while the computed proxy files the
same night under the day that ended, a systematic off-by-one that left both
rows alive as two competing recovery days once the native series was read.
Resolve recovery by a source-aware wake-day key in the user's timezone — the
computed proxy's day is shifted forward to meet WHOOP's wake morning — so one
night collapses to one canonical row without re-dating the stored stamp and
without a backfill. Thread the same resolution through the wellness reader, the
coach nudge trend, and the coach snapshot, which had each read recovery raw.
The native WHOOP recovery and the computed proxy stamp the same night on
opposite clocks: WHOOP files the wake-morning instant (day D), the proxy files
noon-UTC of the day that ended (day D-1). Bucketing raw measuredAt by calendar
day leaves both alive as two competing recovery days.
Bucket each source by the user's local wake-day instead — shifting the proxy's
day-that-ended stamp forward one day to meet WHOOP's wake-morning stamp — so a
single night collapses to one canonical row while distinct nights stay
separate. Resolve the coach-nudge recovery trend and the coach-snapshot dedup
through the same path so no surface blends the two series or drops a genuine
native value on a numeric tie.
Convert the glucose figures the coach snapshot builds to the user's unit and
carry the unit into the prompt, so the coach speaks mmol/L to a mmol/L user
instead of a bare mg/dL number — the one glucose surface the unit work had not
reached. Aggregate the series stats over raw values and convert each figure
once, matching the detail page and the FHIR export so a multi-reading mean no
longer differs in the last decimal between surfaces. Drop the fixed day count
from the median label, which the windowed median value cannot honour.
Add OpenAPI registry entries for the two endpoints the iOS client now depends
on — the measurement series read it charts from and the provider endpoint it
gates coach visibility on — so the contract source of truth covers their
response shapes and CI catches a future drift. Give the consent active-receipt
dedup a deterministic id tie-break so the unique-index build cannot abort on an
identical-timestamp pair, and map a concurrent first-grant unique violation in
createReceipt to the winning receipt instead of a 500.
Document GET /api/measurements/series and GET /api/user/ai-provider in the
OpenAPI registry — both are consumed by the iOS client (charts and Coach
visibility gating) but were absent from the spec.
Break migration 0159's active-receipt dedup on (created_at, id) so an exact
created_at tie still leaves one survivor, keeping the unique index build from
aborting 23505 on affected tenants.
Catch P2002 in createReceipt: two concurrent first grants both reach create,
the loser re-reads the winning active receipt and returns it instead of a 500.
Restore the efficiency denominator the pre-unification score derived when a
night carries asleep and awake time but no explicit in-bed record — a common
Apple Health shape. The canonical reconstruction returns a null in-bed figure
for those nights, which dropped the efficiency sub-score and silently
reweighted the composite. Fall back to asleep plus awake minutes when no real
in-bed record exists, keep the recorded figure when present, and leave the
denominator null only when neither signal exists, so the multi-source dedup
is retained without shifting historical scores for that class of night.
Nights with AWAKE rows but no IN_BED row (a common Apple Health shape)
lost their efficiency sub-score after the canonical reconstructSleepNights
migration, silently reweighting the composite and shifting historical Sleep
Scores. Restore the synthesised asleep + awake fallback on top of the deduped
per-night totals: keep the real IN_BED figure when present, synthesise when
only asleep + awake exist, stay null when neither.
Mint the web AI-consent receipt inside a serializable transaction with an
in-transaction recheck, backed by a partial unique index over one active
receipt per user and kind, so two concurrent mounts can no longer leave two
active grants; a duplicate maps to a no-op mint. Supersede an active row
against the server clock rather than the client-supplied timestamp so a
backdated grant cannot invert the audit chain, and revoke through a single
atomic update. Rate-limit the consent endpoints per user. Extract the
hand-rolled sub-page back link into one component and adopt it across the
four pages that duplicated it.
Two follow-ups to the active-receipt uniqueness work:
- Migration 0159 now revokes all-but-the-newest active row per
(user_id, kind) before building the partial unique index. Without the
pre-flight dedup the CREATE UNIQUE INDEX would abort on any tenant that
already carries duplicate active grants — exactly the tenants the fix
targets — and fail the deploy.
- createReceipt stamps the superseded row's revoked_at with a server
clock instead of the client-supplied signedAt. A backdated signedAt
would otherwise mark the prior row revoked before its own signed_at,
inverting the audit chain. The new row keeps the client signedAt.
Give each WHOOP collection its own incremental cursor in a resource_cursors
map so a slow or rejecting collection no longer drags the sync window forward
for its siblings; legacy connections fall back to the shared last-synced
timestamp. The update webhook now carries the record id and triggers a
targeted single-record refresh instead of re-walking the collection, and a
full sync anchors at a fixed deep start so a backfill covers the span an
incremental tick skips. Cursor advances are a single atomic update guarded by
GREATEST so the independent resource queues cannot race each other backward.
Ingest row shape, source tagging, and upsert keys are unchanged.
Route the derived Sleep Score through the canonical night reconstruction
instead of its own UTC-day grouping, so a night logged by more than one
source is deduplicated to a single writer rather than summed. The score and
the readiness that folds it now read true per-night totals; multi-source
nights drop to their real figure. Emitted minutes and the night stage map
round to integers.
Resolve recovery to one canonical series — WHOOP-native when present, the
computed proxy as fallback — in the wellness reader, the doctor report, and
the coach snapshot, and stop presenting the proxy and the native score as two
numbers. A WHOOP-connected account's recovery now reads the native value on
the tile, the chart, and the printed report alike.
The four WHOOP resources are independent pg-boss queues that run concurrently
for the same user, so the read-modify-write that merged one cursor key into the
map and wrote it back could let a sibling's write clobber a just-advanced
cursor. Replace it with a single jsonb_set UPDATE that merges in place under the
row lock, and GREATEST so the shared last-synced instant only moves forward.
The resource key is parameter-bound and asserted against the closed resource set
before it reaches the JSON path.
RECOVERY_SCORE is written by two sources for the same day: the WHOOP-native
percentage from the device sync and the COMPUTED proxy from the nightly
readiness job. The wellness reader hard-filtered to source COMPUTED, silently
dropping every ingested native row, and the doctor PDF blended both sources
into one min/avg/max. The COMPUTED proxy also equals the readiness blend
verbatim, so the Coach prompt carried the same number under both READINESS and
RECOVERY_SCORE.
Add resolveCanonicalRecovery: per day, keep the WHOOP row when present, else
the COMPUTED proxy. Drop the COMPUTED-only filter for RECOVERY in the wellness
reader and resolve to the canonical row; STRESS and STRAIN stay COMPUTED-only.
Resolve the doctor PDF wellness summary the same way so the report reads the
native value, not a proxy/native blend. Dedup the Coach snapshot: omit the
recovery line when it mirrors readiness (the no-native case), keep it when a
WHOOP-native value differs. Document the WHOOP-over-COMPUTED resolution on the
sync delta feed, which already mirrors each row with its source so the client
resolves one canonical series rather than charting two.
A WHOOP-connected user's recovery tile, chart, and PDF flip from the proxy to
the native value.
Glucose stays stored canonical mg/dL; conversion now happens at the
serialization boundary through the one convertGlucose/resolveGlucoseUnit
helper rather than ad hoc at render time. The series DTO reports the user's
unit and converts its points and stats; the CSV export converts and emits a
unit column to match the FHIR export; the blood-glucose page and stat strip
honour the preference; the median label declares its trailing-90-day window.
A mmol/L user now reads the same converted value on every surface and in
both exports instead of a mg/dL CSV against a converted FHIR document.
The workout, values, score, and medication detail pages each hand-rolled
the same ghost Button + Link + ArrowLeft back-nav block. Fold it into a
single BackLink (href + label + optional data-slot) so the affordance
reads identically — same hit area, icon, and left-bleed alignment — on
every page and lives in one place. Each page passes its existing label
translation, so no new i18n keys are introduced and the markup stays
byte-identical.
POST /api/consent/ai, POST /api/consent/ai/web, and GET/DELETE
/api/consent/ai/latest had no rate limit, so a scripted loop or a
stolen session could pin the receipts table. Add a shared per-user
bucket (20/min, keyed by user id) mirroring the coach-chat ceiling,
checked right after requireAuth, returning 429 with the X-RateLimit
headers attached. The web heal endpoint runs on every AI-settings
mount, so the bucket is generous enough to never throttle normal use.
Two concurrent web-grant heals could each pass the read-then-create gap
and insert a duplicate active ai_full receipt; the revoke path then had
to chase one row at a time. Wrap the web-grant mint in a Serializable
transaction with an in-transaction re-check, and add migration 0159 — a
partial unique index over (user_id, kind) WHERE revoked_at IS NULL — as
the structural backstop. createReceipt now supersedes any active row of
the same kind before appending, and revokeLatest flips revoked_at via a
single atomic updateMany, so the append-only audit chain is preserved
while at most one active row ever exists.
Fix the inline mmol/L example (100 mg/dL converts to 5.5, not 5.6) and narrow
the series unit docstring to the surfaces actually converted at this layer.
Replace the single shared last-synced cursor with a per-resource cursor map
on the WHOOP connection. Recovery, sleep, workout, and cycle each advance only
their own cursor on a successful fetch+upsert, so a slow or 403-ing collection
never drags the incremental window forward for its siblings and its own
late-arriving records stay inside the overlap. Legacy connections fall back to
the shared last-synced instant, so no historical state is lost.
Add single-record fetch-by-id client routes and per-resource by-id syncs. The
update webhook now carries the resource id through the queue payload, so the
worker resolves and upserts that one record immediately rather than re-walking
the whole collection on the next overlap tick.
Anchor a full sync at a fixed deep-history point instead of returning no lower
bound, so a backfill covers the multi-year span an incremental tick would skip
and a stale cursor cannot silently narrow it.
Glucose is stored canonical mg/dL but the series DTO, CSV export, and detail
page emitted raw mg/dL while FHIR already converted to the user preference.
Convert at serialization through the single convertGlucose helper:
- series route sets unit from the user's glucoseUnit and converts each point
- CSV export converts BLOOD_GLUCOSE rows and emits the unit in the unit column
- the detail page + stat strip honour the preference via the chart value scale
- declare the trailing-90-day window in the glucose median label across locales
mmol/L-preference users now see the blood-glucose page, stat strip, iOS series,
and CSV in mmol/L; mg/dL users are unchanged.
Replace the divergent UTC-day night reconstructor in the Sleep Score with a
thin adapter over reconstructSleepNights, the shared engine the dashboard,
hypnogram, and doctor surfaces already read. The old path bucketed rows by UTC
calendar day and never deduplicated writers, so a multi-source night (WHOOP +
Apple Health for the same sleep) summed both writers and roughly doubled the
total. The adapter inherits session-clustering, local-wake-day keying, and
multi-source writer dedup, then derives the score-only fields the canonical
night does not carry: REM and Deep minutes from the stage map, the stage-
breakdown flag from any granular stage, and the midpoint from the asleep span.
Select source and deviceType in the Sleep Score read so the writer-dedup
ladder fires, and thread the user source priority through so the chosen writer
matches every other sleep surface. Round emitted asleep and in-bed minutes to
whole numbers, and round the per-stage map in the sleep-night DTO, matching the
integer contract the native client decodes.
A multi-source user's sleep score and the readiness score that folds it drop
once the double-count is removed, reflecting the true single-writer night.
GET /api/user/ai-provider now surfaces whether any provider can serve the
user — a personal key, a self-hosted local model, or the operator's
admin-managed key — via aiAvailable plus a managedBy origin. Previously a
server- or admin-managed provider read as a null provider, so a client with
no personal key resolved the coach as unconfigured and hid it even though
the coach worked server-side. Availability mirrors the real resolution order
and exposes no admin secret (the managed key ciphertext is reduced to a
presence boolean).
Add aiAvailable + managedBy to GET /api/user/ai-provider so a server-managed
or local provider is reported as usable even without a personal key. Resolution
origin comes from a presence-only helper that mirrors provider resolution
order; no admin keys or endpoints are exposed.
Merge the rollup/live averaging parity guard. The audit confirmed the
rollup tier already computes mean-of-rows (per-bucket AVG over raw rows,
coarser tiers re-aggregate raw rather than folding DAY) and the live
fallback mirrors it via count-weighted recomposition. This test pins the
invariant so a future change cannot silently reintroduce a
mean-of-daily-means divergence on a coverage flip.
Below three observed cycles the prediction is a population-prior guess, not a
data-grounded estimate. The web calendar already hid the fertile window and
predicted ovulation in that state; make the gate structural in the DTO mapper
so iOS — and any client reading prediction.* directly — cannot paint a window
the rest of the app refuses to show. ovulationConfirmed stays goal-gated only:
a confirmed shift is observed data, not a prior.
Add a per-phase "context" line under cycle.phaseEducation.context.* that
expands the existing descriptive-only education with what someone might
notice — energy, sleep, basal temperature, mood — and a gentle framing of
normal variation. The original whatsHappening lines are kept verbatim; this
sits below them as a second, deeper paragraph in the same warm,
medically-grounded, non-prescriptive register.
Wire the new leaf through phase-copy.ts (PHASE_CONTEXT_KEY) and render it in
PhaseEducationCard, and add the copy across all six locales.
Integration coverage for the new fields: a cervix observation round-trips
POST → GET on the day-log route; the cycle-prefs GET defaults
secondarySymptom to MUCUS and a PATCH to CERVIX persists; and the
backup/restore path preserves both the cervix day-log signs and the
profile's secondary-symptom choice.
- Advanced cycle settings gains a "secondary fertility sign" selector
(cervical mucus default | cervix observation). It sits below the priors
in the advanced settings so it never forces itself on a casual user.
- The log-day sheet shows the cervix entry — position / firmness / opening
chips — only when the user picked cervix observation; otherwise the
cervical-mucus picker stays as before. Progressive disclosure: exactly
one secondary sign is offered at a time so the sheet never overwhelms.
- Web client types, the cyclePrefs patch, and the day-log payload builders
carry the new fields; copy added across all six locales.
Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.
- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
second sign the engine confirms against. Casual users keep temperature
+ mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
(LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
columns (migration 0157), wired through Zod, the day-log upsert, the
DTO, the sync feed, backup/restore and OpenAPI. Additive and
back-compatible: existing profiles backfill MUCUS, existing day-logs
keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
day followed by three closed days — the cervix-closure rule) instead of
the mucus peak. The double-check still requires BOTH the temperature
shift AND the secondary peak: a single cervix sign never confirms, and
the still-learning gate and conservative posture are unchanged.
Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
A stored positive ovulation test (LH surge) was ignored by the
predictor. Wire it in as a Marquette/Fehring anchor: a positive surge
predicts ovulation ~24-36h later, so the estimated ovulation is set one
day after the last positive LH test in the current window, sharpening the
estimate over the calendar back-calculation — most valuable in the
thin-data (<3 cycle) case where the median/luteal back-calc is weak.
The LH surge is a SIGNAL that refines, never a sole assertion: it
re-anchors the still-estimated ovulation but never flips
`ovulationConfirmed` (a single indicator is not the symptothermal
double-check), and a confirmed symptothermal/temperature-trend ovulation
always wins over it. The still-learning gate and the un-confirmed status
are preserved. A stale prior-cycle surge whose next-start lands in the
past is rejected.
Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before
ovulation.
A fever, illness, alcohol or a late/short-sleep reading can fake or mask
a temperature rise. Add a per-day `temperatureExcluded` flag on
CycleDayLog (migration 0156): when set, the engine drops the reading from
the cover-line baseline AND the shift evaluation, so a spike neither
fabricates nor masks a rise (Sensiplan: the cover line is drawn over the
last six unbracketed values).
Wired end to end — schema + Prisma, the engine's `detectTempShift`
filter, the Zod input/patch schemas, the day-log upsert (partial merge +
change detection), the read DTO, the calendar/insights/coach selects, the
backup round-trip, and the OpenAPI contract (a new boolean on the day-log
and calendar-day DTOs for the iOS client). The log-day sheet gains a
"mark reading as disturbed" toggle that appears once a temperature is
entered, translated across all six locales.
Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve
auswerten" — Störfaktor bracketing.
The mucus peak was taken as the last egg-white/watery day with no
confirmation, so a stray late fertile-mucus entry corrupted the peak.
Implement the Sensiplan Höhepunkt rule: the peak day is the LAST
best-quality (egg-white/watery) day that is followed by at least 3
consecutive drier observed days, each strictly lower quality. The peak is
only confirmable retrospectively, after those 3 days, so a stray late
egg-white entry not yet followed by 3 drier days cannot move an
already-confirmed peak. Quality is scored on the t/f/S/+S ladder;
unobserved days are skipped so a logging gap does not break the count.
Citation: Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP
"Zervixschleim beobachten".
The BBT shift detector only applied the strict 3-over-6 rule (3 readings
above the cover line, the 3rd >=0.2C above), so it under-detected
ovulation on common ovulatory patterns. Add the two published
Ausnahmeregeln:
- 1. Ausnahmeregel (slow/staircase rise): when the 3rd high reading is
above the cover line but not >=0.2C above, a 4th reading that need
only be above the line confirms the shift.
- 2. Ausnahmeregel (one value falls back): when exactly one of the three
readings falls back to/below the cover line, it is discounted and a
4th reading that again clears 0.2C above the line confirms.
The result now carries a `rule` (0|1|2) provenance field and the
`evaluationCompleteDate` (evening of the 3rd or 4th high reading). The
rules follow the Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann
standard and are mutually exclusive by construction.
The hard upper bound clamped cycle lengths at 45 days, force-excluding any
longer cycle as an outlier before the robust statistics ran. Cycles over 35
days are oligomenorrhea but still real, and adolescent and perimenopausal users
routinely run to fifty days or more — exactly the irregular users for whom an
honest estimate matters most. Clamping their genuine cycles biased the estimate
toward the population middle.
Raise the ceiling to 60 days. It stays a coarse gross-error backstop; the
median-plus-MAD fence and the missed-log heuristic still do the real outlier
work, and because the fence is computed before exclusion, raising the ceiling
does not move it — it only stops the hard rule from pre-empting the fence on
long but consistent cycles.
The predictions panel suppressed its fertile-window and ovulation rows while
fewer than three cycles were observed, but the month calendar did not — so a
brand-new user who logged a single period saw the grid paint a confident
fertile-window shading, a predicted-ovulation dot, and full phase bands off a
population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation
is unreliable below several recorded cycles, so the grid was asserting days it
could not know.
Thread the same still-learning gate into the calendar build: while fewer than
three cycles are observed, suppress the fertile window, the ovulation dot, and
the phase band (the predicted-period bar stays, mirroring the panel). Surface a
top-level stillLearning flag on the calendar response so the client renders a
calm "learning your cycle" banner over the grid instead of a bare, falsely
confident calendar. The flag is additive and back-compatible; older clients
ignore it and degrade to the previous render.
Also pin the observed-cycle count to a single source — the engine's
post-exclusion value — so the response never carries two divergent counts.
The dose-ledger adherence builder only tallies scheduled medications — a
medication with no schedule is PRN/as-needed and carries no expected dose,
so it is excluded to keep a fabricated 100 % off the clinical report. The
shared fixture seeded a schedule-less medication, which now yields an
empty compliance map; seed a window so the default-sections test keeps a
compliance entry.
The BD-Zielbereich headline, the Health-Score BP pillar and the coach
number now all read the same trailing-90-day in-target window. Re-derive
the integration expectations from the fixtures: the analytics headline is
the independent 90-day figure (distinct from the 30-day and all-time
windows), and the BP pillar grades from the 90-day window when readings
predate the trailing month rather than dropping out.
The shared validateEntryInstant guard now owns the future clock-skew
allowance, so the medication-local TAKEN_AT_CLOCK_SKEW_MS export is dead.
Remove it; medication intake keeps its tighter 5-year floor via
TAKEN_AT_MAX_AGE_MS. Clears the dead-code gate.
Coherence release: blood-pressure in-target unified on a labeled, thin-data-gated
90-day window across tile, health-score pillar and coach; the dashboard ring and
insights card built from one shared health-score input builder; the coach and the
doctor-report PDF/FHIR adherence drawn from the same dose ledger as the detail page;
and a shared entry-timestamp plausibility guard on measurement and mood. Several
displayed figures become correct-but-different (BP headline window, adherence).
Review follow-up to the shared entry-instant guard. The past-floor refine
re-ran the full plausibility predicate, so a future-dated instant tripped
both refines and the 422 carried a nonsensical "too far in the past" message
alongside the correct future one. The floor refine now checks the past bound
alone; a future value emits only the future message, matching the pre-refactor
single-message contract.
Also harden the Health-Score BP inputs and de-duplicate the tile-window
derivation surfaced in the same review:
- the prior-week graded score now keys off the prior-week window's own
sufficiency rather than the current window's, so the week-over-week graded
delta never pairs a confident current value against a thin-sample prior one;
- the BD-Zielbereich 90-day count + effective span derive through one shared
`deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick
slice can never compute the gate or the label span differently.
Only medication intake bounded its capture instant — a hand-rolled POST or a
faulty import could backdate a measurement to epoch zero or forward-date a
mood entry into next year, and both sailed through validation.
Extract the intake bound into a shared `validateEntryInstant` helper: reject
any instant more than a five-minute clock-skew tolerance in the future, and
reject anything before a 1900 far-past floor. Callers can clamp the past
window tighter — medication intake keeps its five-year floor — and override
the past-bound message to preserve an established contract.
Apply it to the measurement create/edit schema (`measuredAt`), the mood
schema (`moodLoggedAt`), and the cycle period + day-log (`loggedAt`);
medication intake now delegates to the same helper. The capture forms gain a
matching `max` on the datetime picker so a future instant can't be selected
in the UI either. The wire shape is unchanged, so the OpenAPI contract holds.
The BD-Zielbereich tile narrated a static "· 90 T" scope even for a user
with only a few weeks of history or a handful of readings — a headline that
reads like a stable quarter-long average when it is really a thin sample.
Add a shared, reusable window-confidence helper that, given a window length
and the readings observed inside it, returns the EFFECTIVE label span (the
real calendar span capped at the window, so "· 23 T" until ~90 days of
history exist) and a sufficiency flag (false below five readings). The helper
is metric-agnostic so the glucose time-in-range / GMI and sleep tiles can
reuse it.
Wire it into the in-target headline: below the confidence floor the tile
shows "sammelt noch Daten" instead of a hard percentage, with the dynamic
span label; above it the percentage shows with the real span. The
Health-Score BP pillar reads the same sufficiency notion through the shared
input builder, so a thin-data user no longer gets a confident pillar grading
off a few readings — the all-time rate still rescues a deep-history account
with a quiet quarter. The 90-day pair count and effective span thread from
the analytics route and the dashboard snapshot through to the tile.
The dose-ledger adherence rate divides taken by taken + missed (deliberate
skips excluded), so the PDF compliance table's denominator column no longer
equals Taken + Skipped + Missed. Relabel it "Expected" so each row stays
internally coherent — Taken + Missed = Expected, with Skipped shown alongside
as informational and the rate reading Taken / Expected.
Also tighten the coach adherence-block wording: the headline is the
dose-weighted pool of taken / (taken + missed) across the user's scheduled
medications — exactly one medication's card rate for a single-med user, and the
honest overall adherence for several — never a per-day or per-week denominator
of its own.
The doctor-report PDF and FHIR export tallied raw intake rows — taken when
takenAt was set, else skipped, else missed, with every row counting toward the
denominator. That double-counted cross-source duplicate rows (a reminder row
and an API row for the same slot both read as taken), ignored band timing (a
pre-window or ad-hoc take counted as a plain take, a pending row counted as a
miss regardless of the cutoff), and honoured no cadence, so off-week rolling or
cyclic days could enter the tally. A clinician comparing the printed adherence
% against the app's medication detail page could see two different numbers for
the same drug.
Route the report through the same dose-ledger compliance the detail page uses:
per scheduled medication, mint the cadence-aware bands over the report window
and tally taken / missed / skipped from the unified dose-history ledger, with
the denominator being taken + missed so the renderers' taken/total rate equals
the ledger rate. PRN / as-needed medications stay excluded. The compliance
assembly is extracted into a pure, unit-tested `buildLedgerCompliance` helper;
the medication query now loads the archived schedule eras and the report reads
the user timezone so past days attribute against the schedule that was live
then.
The coach snapshot built its own medication-adherence figures: a per-day and
per-week timeline tallied off a "countable ledger rows in window" denominator
that differs from the cadence-aware `calculateCompliance(...).rate` every other
surface — the medication card, the detail page, the dashboard pillar, the
insights — reads. On an irregular, rolling, or partial-window schedule the two
denominators diverge, so the coach could reason from a rate the card never
shows the user.
Route the coach's headline adherence % through `calculateCompliance(...).rate`
with the medication context (the ledger path), aggregating taken / (taken +
missed) across the user's scheduled medications over the coach window — exactly
the arithmetic the card's rate uses. The snapshot now carries a single
`compliance.rate` headline alongside the supplementary timeline, so the coach
quotes the same number the card renders.
The Personal Health Score was assembled differently by its two callers. The
analytics route (the Insights card) graded the BP pillar off one window and
passed the prior-week values for the week-over-week delta; the dashboard
snapshot (the hero ring) graded it off a different window and omitted the
prior-week values, so the ring's delta zeroed BP out and the pillar's presence
gate could flip. The same user could see two different scores and two
different bands on two surfaces.
Extract `buildHealthScoreBpInputs` — one pure builder that turns the current
and prior-week in-target envelopes into the BP-pillar inputs (the 90-day rate
with an all-time fallback, the graded clinical-proximity score, and the
prior-week counterparts for the delta). Both callers now run the same two
in-target probes and feed the pillar through this builder, so the window
choice, the fallback, the graded-score wiring and the delta inputs are
identical by construction and can never drift between the surfaces again.
The dashboard ring now runs the prior-week probe it previously skipped, so its
week-over-week delta reflects BP movement to match the Insights card.
The "BD im Zielbereich" share was narrated on three different windows on the
same screen: the dashboard headline read the trailing 30 days, the dashboard
Health-Score BP pillar read all-time, and the analytics route's pillar read
all-time while its headline read 30 days. A user could see one in-target rate
on the tile and a pillar graded off a different window directly above it.
Standardise the headline, the Health-Score BP pillar input, and the
week-over-week delta on a single trailing-90-day window everywhere — a span
wide enough to stay stable yet recent enough to track current control.
`computeBpInTargetWindows` and the rollup fast-path now both surface a
`last90Days` window; the analytics route and the dashboard snapshot route the
headline and the pillar through it, and both feed the pillar from the identical
value so the dashboard ring and the insights card can never disagree.
Label the window in the tile ("BD-Ziel · 90 T") so the scope is visible; the
30-day figure stays as the short-horizon caption and the all-time aggregate
stays carried for the detail view. Visible-number change is expected: the
headline now reflects the quarter rather than the month.
The v1.16.13 catalog curation deactivated the default tags happy,
excited, hydrated, music and the factor_sleep_quality RATED factor.
Integration tests that run the real migrations against testcontainers
Postgres still hard-coded the pre-curation catalog and failed.
Swap the placeable built-in tag from happy to grateful (a long-standing
active feelings tag) and the seeded RATED factor from factor_sleep_quality
to factor_sadness (still active, same 1..5 inverse scale). Assertions and
intent are unchanged — only the fixtures move onto the live catalog.
App-wide service-worker registration (installable + offline, authenticated
pages kept out of the cache, logout wipe), era-correct historical dose-edit
attribution, CSV intake stock consumption, WHOOP late-workout ingest, web AI
consent-receipt minting, the layout partial-PUT section merge, the curated
mood-tag catalog, and a sweep of mobile header fixes.
The header comment still described WHOOP_RECOVERY_SLEEP_OVERLAP_MS as 24 h
and claimed workout ingest used the narrow default. The constant has been
7 days, and workout ingest now shares it so a late phone-sync to the WHOOP
cloud no longer drops the workout outside the window. Comment only.
The app-wide service-worker work routes the fallback literal through a
`@sw-version-fallback` block-comment anchor that the prebuild generator
keys on, placed between the `||` and the literal. The pre-existing
fallback-shape assertion predates the marker and matched only the inline
form, so it failed on the marker comment. Widen the pattern to allow the
optional anchor while still pinning that a reachable "v" literal exists.
The Coach floating button was mounted unconditionally, so a demo
visitor could tap it and send a message. /api/insights/chat is not in
the proxy's demo-mutation allowlist, so the send returned a raw 403.
Demo is read-only by design, so the FAB has no job there — gate its
mount on !demoMode and pin the contract in the hoist test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The medications header used flex-wrap, so its action group (take-all
+ customize wrench + Add) dropped to a second row below the title on
narrow viewports. Drop the wrap and let the title block shrink: title
div gets min-w-0, subtitle truncates, action group keeps shrink-0 —
the wrench and Add stay top-right inline with the heading.
The mood, measurements and dashboard headers shared the same latent
root cause (title div missing min-w-0, subtitle/greeting not
truncating, action group not shrink-0). Apply the same minimal guard
so none of them can wrap or compress the actions either.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The More hub grid tile label was a flex child without min-w-0, so
long compound labels (e.g. "Benachrichtigungen") painted past the
tile's rounded border instead of wrapping. Give the span min-w-0
and tightened leading so it wraps cleanly within the tile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The overview hero's inline "Ask the Coach" button sat in a
flex-wrap row with no justify-end, so it rendered left under the
hero copy. Pin it to the right edge of the hero's left column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three home-screen shortcuts (Add measurement, Today, Medications)
deep-linking into the app, each tagged `source=shortcut`. Add a 192×192
maskable icon alongside the existing 512 so adaptive launchers mask the
right size instead of downscaling the 512 or clipping the un-masked 192
"any" icon on circular masks.
No screenshots: the repo ships no real screenshot assets and fabricating
them is worse than the richer-install-dialog gain.
`public/sw.js` carries a `|| "..."` fallback literal used for `CACHE_VERSION`
when `importScripts('/sw-version.js')` is unavailable. The literal had drifted
to `v1.4.43`, four-plus minors behind `package.json`; in any environment where
the generated file is missing this keyed every cache to the stale version and
turned the activate-step eviction into a no-op — the exact bug the generator
was written to kill.
Extend the prebuild generator to rewrite the fallback literal in `sw.js` to the
current `package.json` version on every build, anchored on a
`/* @sw-version-fallback */` marker (the build fails loudly if the marker is
gone). The literal and the generated `sw-version.js` can no longer diverge.
The SW test now reads the fallback straight from the source so it tracks the
literal instead of hard-coding it.
The Web-Push opt-in registered its own `/sw.js` before subscribing. With
the app-wide registrar now installing the worker for every user, that
second `register` call is a redundant source of truth at the same scope.
Replace it with `navigator.serviceWorker.ready`, which resolves with the
active registration once the global worker is controlling the page.
Now that the service worker is registered app-wide, cached navigation
HTML survives a logout. The everyday routes carry no server-rendered PII,
but on a shared device a clean session boundary should leave nothing in
CacheStorage. On a successful logout, best-effort delete the
`healthlog-pages-*` caches. The static cache (hashed chunks, icons) is
left intact so the next login does not re-download the shell. The wipe
never blocks the redirect to the login page.
With the service worker now registered for every user, the network-first
page cache sees every authenticated navigation. The everyday app routes
are client-fetch-only shells with no server-rendered PII, but the
`/c/[token]` clinician-share view renders health values straight into the
HTML and emits `no-store`. The page cache ignored the header and stored
it, so a revoked or expired share could still render back offline.
Gate `cache.put` in `networkFirst` on a new `isCacheableNavigation`
check: skip any response carrying `Cache-Control: no-store` (the
principled rule for any present or future server RSC that renders user
data) and skip the `/c/*` path outright as belt-and-braces. Ordinary
navigations still cache unchanged.
Until now `/sw.js` registered only behind the Web-Push opt-in, so any
user who never enabled notifications ran with no service worker — the
offline shell cache, the precached app shell, and the network-first page
cache were all inert. Add a production-gated `ServiceWorkerRegistrar`
that installs the worker for everyone, deferred to the `load` event
wrapped in `requestIdleCallback` so install/precache stays off the
first-paint critical path. Mount it beside `VersionPoller` in the root
providers.
The registrar only installs. `VersionPoller` remains the sole owner of
the update→reload path (poll `/api/version`, then unregister + cache-wipe
+ reload on a server-ahead deploy); no `updatefound`/`controllerchange`
handling lives here so the two cannot double-fire. `updateViaCache:
"none"` forces a network byte-check of `sw.js` on every update check so a
cached worker can never mask a new deploy.
`GET /api/sleep/night` summed second-precision stage rows into asleep /
awake totals and serialised them unrounded (e.g. 433.4999), which also
violated the OpenAPI integer contract. Round the present minute values;
`inBedMinutes` / `awakeMinutes` stay null when the night never saw that
stage.
The WHOOP workout sync used the default 1 h incremental overlap while
recovery and sleep use the wider re-score overlap. The activity collection
filters on the workout's own time range, not on when it reached the cloud,
so a workout whose phone sync lands more than an hour after its window fell
permanently before the cursor and was never ingested — operator-visible
data loss. The workout sync now reuses the recovery/sleep overlap; the
externalId upsert keeps the re-fetch idempotent.
The CSV intake import recorded taken doses but never consumed inventory,
the only intake seam that skipped stock consumption, so runway and
days-left overstated for any imported history. Import now calls
`consumeForIntake` per freshly created event, matching the web-take,
status-toggle and bulk paths: the consumption stamp keeps it exactly-once,
the existing duplicate-key skip means a re-import never re-creates the row,
and the hook no-ops for as-needed / no-inventory medications.
The write/edit slot resolver loaded a medication's archived schedule
revisions but dropped them before running band attribution, so editing a
historical intake snapped it onto the LIVE schedule's slots instead of the
era that was valid at the dose's time. The read side (dose-history,
compliance) already era-resolves; the write side now matches.
`loadAttributeMedication` threads `scheduleRevisions` into the attributor
projection. Both `resolveSlotForWriteByBand` and `resolveForcedSlotForWrite`
now pick the correct era for any past instant.
The default-tag curation deactivated the redundant emotion and day-type
tags but left factor_sleep_quality active. A manual 1-5 sleep rating
duplicates the measured sleep-tracking surface, so the factor leaves the
seeded set alongside the rest, with work and sadness remaining the RATED
factors. Reference-data only; historical entries keep resolving since the
read joins the catalog by id, not is_active.
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.
Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.
Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.
Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
A PUT that omits sections (an iOS tiles-only reorder) reset the user's
section customization to defaults, and symmetrically a sections-only PUT
reset the stored tiles. serializeInsightsLayout now takes the resolved
stored layout and falls back to it — not to defaults — for whichever
dimension the body omits; the PUT handler loads the stored layout first.
A first-ever single-dimension PUT (no stored layout) still fills
defaults, so the v2 blob stays complete. Pins the merge behaviour with
three unit cases.
Deactivate the redundant default tags happy, excited, hydrated
("Wasser getrunken") and music; re-activate overtime in the work
category (0126 had retired it); add a new default work tag praise
("Lob" / "Praise", Lucide ThumbsUp) with i18n across all six locales.
factor_sleep_quality stays a RATED factor: its removal was premised on
a dedicated sleep-quality slider that does not exist in the capture
surface, so dropping it would lose the signal with no replacement.
Reference-data only — is_active flips plus an idempotent keyed upsert,
no schema change, no per-user data touched; historical tag links keep
resolving their labels.
The markAsFirstUseAt case hardcoded 2026-05-14, whose 30-day in-use window
lapses at noon UTC on 2026-06-13 — past that the expected state flips to
EXPIRED and the assertion fails. Anchor first-use to one day ago so the
window is always open relative to the run.
The analytics and derived-insight reads served stale for ten minutes, but a
single reader visits the dashboard and insights every twenty to thirty
minutes — so nearly every return landed past the window and paid the full
cold rebuild instead of an instant stale serve. At an hour the return
serves the prior snapshot immediately and refreshes in the background, and
each stale-serve re-warms the entry; the dashboard's time-derived fields
stay bounded and self-correct through the snapshot client's poll.
The low-supply notice moves onto the adherence meta row beside the streak —
a slot every card already reserves in every state — so a card that is low
on stock no longer inserts a row that pushes its bars out of line with the
card beside it. The gap between the drug class and the first line tightens
at the same time.
The medications list, compliance summary and supply reads now refetch on
every mount. App Router unmounts the page on navigation away and remounts
it on return; inside the client staleTime that return served a cache that
could be minutes old, hiding a dose logged on another device or surface. A
Playwright spec guards the return-navigation refetch.
A dose can now consume a fraction of a unit — a half, third or quarter
tablet for a split pill. The wizard offers a curated fraction set beside
the whole numbers (the most error-resistant input); units-per-dose and the
per-container counts widen from Int to Decimal, so a 30-tablet pack dosed
at a half reads 29.5 after one dose and the days-left projection follows.
The consumption ledger stamps and refunds the exact fraction, and existing
whole-number doses are untouched. OpenAPI regenerated — the three unit
fields move from integer to number — and the units-per-dose hint is
updated across the six locales.
Also anchors a first-use date relative to now in an inventory route test
that had aged past its 30-day in-use window.
The coach launcher deliberately opens the side drawer in place now —
the spec expected the old navigation and waited for a URL that never
comes; it asserts the drawer (bottom-sheet on the phone viewport) and
the nudge-dot lifecycle instead. The metric and slug status cards
rendered locale-dependent content in the hydration pass, which React
flagged as a mismatch (#418) on the dashboard and the mood page — they
ride the established mounted gate now, pinning the server branch for
the first paint. The midnight flake in the intake bucket tests is
pinned to a fixed clock: the fixtures minted UTC days while the route
buckets Berlin days, so runs between midnight and two failed.
The layout hook, the cap mirrors and the layout type shipped unused —
the dead-code gate flags them; the section reads everything through the
manage query and the caps surface as server 422 toasts.
Switching an as-needed medication onto a fixed schedule archived no
revision (there were no prior rows), so the era segmentation attributed
the entire schedule-less stretch to the new schedules — every as-needed
day would have read as missed across the era-aware consumers. The flip
now archives an empty-payload era covering the stretch, which expects
nothing. The doctor report's compliance table excludes as-needed
medications: with no schedule every logged intake is taken by
definition, and a fabricated 100% has no place on a clinical document;
the medication list itself still carries them.
- The escalated card row keeps its time anchor: the red badge carries
the matched window or the overdue-slot value beside it, and the
inline row's range stays visible on mobile, where it was the only
clock on the card.
- Take-all-due collects what the cards offer: a dose escalated past its
catch-up band joins the set with exactly the slot the card's own
button would record; as-needed medications never join.
- The table's stock warning follows the user's runway threshold instead
of a leftover four-dose constant; threshold off means no amber, empty
stays red.
- The card itself shows the remaining runway in the warning tone once
it crosses the threshold — only then, the card stays calm above.
- The low-stock push deep-links straight into the supply tab and its
APNs payload keeps url, runwayDays and thresholdDays; supply tab and
threshold setting link to each other; the reminder-thresholds and
notification-prefs endpoints enter the OpenAPI contract.
A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
Ladder reconciliation ranks every enum source, so the unranked
fall-through needs out-of-enum fixtures now; a stored single-source
ladder resolving one canonical row is pinned as the new contract. The
body-sync test sheds the import its retired weight path used.
Editing any field of a mood entry re-sent its tag set, and archived
tag keys can no longer resolve — the diff deleted the entry's links to
them invisibly, against the archive contract the tag suite just
shipped. Submitted tag keys now govern active links only; links to
archived tags survive every edit, and the explicit purge stays the one
removal path. The insights surfaces — tag breakdown, influence, better
days, crosstab, the model snapshot and the narratives — render the
decrypted custom-tag label instead of the raw key, decrypted once per
tag server-side.
The wide-event type declared loop_p99_ms while the monitor publishes
loop_last_ms — a dashboard built on the declared field would find
nothing; the type now states what is emitted. Ladder reconciliation
collapses duplicate entries first-occurrence-wins, so a duplicate-laden
ladder written through the API can no longer overflow the schema's
eight-entry cap when the settings page echoes the resolved shape back.
WHOOP's body endpoint carries a self-reported profile weight, not a
reading — the overwrite-in-place row took a fresh measuredAt on every
sync, so a stale manual entry kept resurfacing as the newest weight no
matter what the scale said. The sync no longer ingests it and clears
the legacy row off existing accounts on its next pass.
A stored per-metric ladder replaced the default wholesale, so a source
integrated after the user last saved — WHOOP on a pre-v1.11 sleep
ladder, Fitbit on pre-v1.12 ones — never surfaced again: the settings
page renders only the stored array and offers reorder, not add, and
the picker ranked the invisible source behind everything. The resolver
now appends default-ladder sources missing from the stored ladder
after the user's explicit order — visible, reorderable, and ranked
where the old behaviour already ranked them.
The standalone status line above the schedule lines is gone — it
reserved a blank row on every settled card while the overdue tier rode
the async compliance read, which read as a hole between the header and
the schedule. Take-now, late and the overdue escalation now render as
the right-aligned value of the next-intake row, colour and glyph
carrying the urgency; the taken-early guard simply suppresses the
prompt, with the last-intake line below already naming the recent
dose.
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
When two or more medications are due, the page header offers a single
confirm dialog listing them with dose and window. The takes loop the
per-medication intake route rather than the sync bulk surface — slot
attribution, inventory consumption and idempotency stay byte-identical
to individual taps, and a failed medication stays due while the rest
land. (#316)
The hero band, dose tally, verdict and checklist all read one snapshot
query that neither refetches on mount nor on focus — and no mutation
ever invalidated its key, so a dose taken right there stayed visibly
due for up to the next two-minute poll. The snapshot key joins the
medication-dependent invalidation bundle; the intake routes already
hard-evict the server-side bucket, so the triggered refetch returns
post-write data immediately. (#316)
Under the dose count the column now shows roughly how many days the
remaining supply covers — the same coarse schedule-derived estimate the
detail overview renders, so the refill question is answered at a
glance. (#316)
Units-per-dose and the default pack size were settable only through the
wizard's dose step; a manufacturer switch with a different blister size
happens while looking at the supply, so the supply tab now carries a
packaging row with an edit dialog that writes sparsely onto the
medication.
A daily job projects each tracked medication's remaining supply against
its schedules and notifies through the normal channel cascade when the
runway falls below the user's threshold (default seven days, 1-60 or
off, set beside the reminder thresholds). One notification per
crossing: a stamp on the medication row re-arms on refill or threshold
change, and a failed dispatch leaves it clear for the next day's retry.
The GLP-1 card's fixed four-dose flag stays as the locked iOS read
contract; the engine covers it like any other medication.
Production showed multi-second windows where every response — routes,
RSC payloads, static assets — waited together while handler durations
stayed under half a second: the process stalled, and nothing
route-shaped could name it. A self-rescheduling tick now records how
late it fires; a tick past the stall threshold emits its own
event_loop_stall background event, and every wide event carries the
recent worst lag so the first request finishing after a stall is
annotated with it. Measured via a timeout chain rather than
perf_hooks.monitorEventLoopDelay — the histogram's sampling timer races
a sibling read-and-reset interval in the same timers phase and the
stall sample can vanish into the reset window.
Containers can be removed from the supply tab — the endpoint existed,
the UI never offered it; a stamped container deleted later was already
refund-tolerant. The adjust trigger wears the outline button vocabulary
instead of reading as dead text, and the state badge moves inline next
to the dose figures as plain meta text instead of posing as a button in
the action cluster.
A writer whose stage rows all share one timestamp carries night totals,
not segments — reconstructing them as segments paints every stage
flush against the night's right edge. The session picker now prefers a
writer with genuinely timed rows among the granular candidates, so a
stage summary backfilled for a night an actually-timed writer also
recorded no longer evicts the real timeline; summary-only nights still
resolve. The hypnogram itself degrades a summary-shaped session to the
stage breakdown instead of drawing the false skyline.
An open take-window renders as the right-aligned value of the next-
intake row — same label/value columns as every other card line —
instead of a standalone line above the slot; the escalation tiers
(late, very late, taken-early) keep the top line. The coach launcher
opens the side drawer on the page you are on instead of navigating to
the chat page, and grows to the standard FAB size.
The table wrapper clips its children now — the sticky name column's
opaque cell background squared off the left rounded corners. The
dashboard, insights and medications headers share a wrench as their
customize glyph, leaving the slider icon to the target and overlay
controls that already wear it. The header view toggle retires; the
cards-or-table choice lives in the medications settings section.
/settings/medications now hosts the view preference (cards or table)
and the manual-order editor inline, following the dashboard and
insights pattern. The page header's gear icon links there instead of
opening a dialog; the quick view toggle stays next to the add button.
The order editor keeps its grouped active/inactive semantics — a saved
order can never interleave the groups.
The pill trigger paints its own label spans and mounts no SelectValue,
and Radix's item-aligned placement silently skips all positioning when
the value node is absent — the open menu laid out below the
height-locked app shell, fully off-viewport, which reads as a dead
control. The mood and type/source pills on the mood and measurements
lists were affected since the unified filter rail landed in v1.16.1.
Popper placement has no value-node dependency and anchors the menu
under the pill like the date-range popover next to it.
The header slot left of the add button now carries the same Settings2
icon the dashboard and insights headers use for their customize entry
points, so all three pages signal layout customization identically.
- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall
back to ledger-derived pen/dose figures when a medication has no
inventory items; items always win once they exist. The delta branch
on the POST is marked deprecated in favour of the inventory endpoints.
- Expired containers count as available nowhere: one shared supply
summary (ACTIVE/IN_USE with units) feeds the overview row, the
Bestand summary, the list payload, the GLP-1 card, and the runway
estimate; expired units surface as a muted suffix instead.
- The inventory endpoints speak one wire dialect: request fields renamed
to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated.
- The reorder dialog mirrors the page's grouping: active and inactive
sections, each sortable within itself, so a saved order can never
interleave them.
- Polish strings avoid the 2-4 plural trap, sub-dose quantities render
as below one dose instead of approximately zero, and the table's name
sort collates by the active locale instead of hardcoded German.
Every dark-theme arc's light stop now IS the matching palette colour —
readiness green, recovery cyan, sleep purple, stress orange, strain
pink — instead of the indigo/teal/violet neighbours that sat next to
the palette. The Alucard light set keeps its deeper hand-tuned tones
but follows the same five hue families.
The chart-tooltip auto-hide made the button vanish on every hover
tooltip anywhere on the page, which read as a glitch; a tooltip under
the cursor never reaches the lower-right corner anyway. The observer
chain behind it goes with it. The right inset now mirrors the desktop
bottom inset.
The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
Two hardening passes on the consumption stamp:
- consumeForIntake claims the stamp with a single conditional UPDATE
(NULL-stamp predicate + row lock) instead of read-then-write, so a
double tap racing a sync replay decrements exactly once; the loser
sees count 0. restoreForIntake clears the stamp the same way, so
concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
replay that re-posts an already-taken history row, or an edit that
corrects the timestamp of an old dose, never touches today's stock.
The medications page gains a compact table beside the card view — one
row per medication with the same served-due status pill, next-intake
line, cadence-scaled adherence bars, dose-derived stock and the take
and skip actions the cards use, all from the payloads the cards
already read; the list response aggregates stock in the same round
trip. Columns sort tri-state with nulls last, the default order is
yours to arrange — the same drag dialog the dashboard and insights
use, applied to both views — and the chosen view and order persist
per user on their own settings column with field-scoped writes, so
toggling the view never touches the order. Inactive medications pin
to the end, the first column stays put under horizontal scroll, and
the loading skeleton follows the view you left.
The snapshot's thick phase gated on every type the account had ever
logged having rollup coverage — one fresh reading from a new
integration nulled the health score and the blood-pressure extras
while the insights page showed a score for the same account. The gate
now checks only the three types the phase reads, per the established
per-type pattern. Underneath sat the destabiliser: the large rollup
fold deleted the window's buckets and re-inserted without a
transaction or duplicate tolerance, so a racing sync write aborted the
insert and left the delete committed — whole types lost their buckets
until the next clean fold, and accounts with data older than the
five-year fold window re-ran the full backfill on every boot because
discovery scanned unbounded history. Delete and insert now commit
atomically with duplicate tolerance, discovery shares the fold's
bound, the provisional ring says the score is being computed when the
inputs exist and reserves the no-data wording for accounts that have
none, and the greeting leads the band typographically.
Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
The intake plausibility bound rejects a future takenAt by design, so
the fixed calendar dates and the today-evening slot in the bulk suite
rotted into rejections depending on the run date and hour.
A fresh crisis-level blood-pressure reading outranks an overdue dose in
the daily verdict, and once it ages past a day the dose leads again.
The greeting returns to the page header whenever the daily band is
hidden, and the settings toggle says it rides along. The nightly
convergence pass honours the early grace, so a take recorded shortly
before a window that starts at the dose time heals like the write path
credits it. The early-take context line states a fact — last dose
yesterday, last dose n days ago — instead of claiming an early intake
after a late one, the quick-add default pick runs in the profile
timezone, a dose line without a medication name reads cleanly, the
early-grace constant has one home, and the Telegram confirm writes
intake and snooze atomically again.
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
A trailing space from the portal's copy button reached WHOOP verbatim
and answered as an unknown client; the credential schema trims both
fields. The redirect override fell through nullish coalescing, so the
empty string the compose whitelist materialises for an unset var was
sent as the literal redirect target — empty now derives the callback
from the app URL.
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
Hidden states of the floating coach button now leave the tab order and
accessibility tree, its focus ring carries an offset, its icon is dark
on the gradient, an unread nudge is announced once via a live region,
and it sits correctly above the fold on tablet widths and yields to
the onboarding tour. Chart error cards announce themselves, share the
exact height variables of the skeleton band, carry per-chart retry
labels, and a failed chunk renders inside the regular card shell. The
header icon cluster spaces its extended hit areas apart, the tab strip
keeps horizontal paint room for focus rings, the streak row reserves
its slot in every card state, the selection bar clears the bottom nav
on landscape phones, and the version poller keeps an in-memory reload
guard for browsers that block session storage. Mood notes render as
plain paragraphs with a toggle that appears only when text is actually
clamped, the remember control reveals on hover and hands focus to its
confirmation, and the hypnogram names its source and paints only the
lanes the night contains.
Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
A round coach button sits fixed bottom-right on every authenticated
page, above the bottom nav and safe-area inset, with a small dot when a
nudge is unread — replacing the bubble that only existed while a nudge
was pending. The per-chart target-adjust link is gone (the page-level
control remains), the duplicated customize entry on insights sub-pages
collapses onto the tab strip, and the full-width show-all-values foot
button becomes an icon beside the target control. The tab strip's
scroll container keeps four pixels of paint room so pill borders and
focus rings stop clipping at fractional zoom, and the mood tag
breakdown bars adopt the muted opacity the other mood charts use.
Sleep nights collapse per writer instead of per source: several
HealthKit apps land behind the same source value, so a phone's coarse
awake and in-bed blocks could blend into or fully constitute a night
whose stages another writer had recorded. The winning writer per
session is the one with the most distinct granular stages, then the
user's source ladder, and its rows are kept whole. The device type is
threaded through every sleep read including the stage stacked bar, and
the sync overlap for late-arriving nights widens from one day to seven
so a night scored after the cursor passed is fetched on the next tick —
assembly is read-time, so affected nights heal without manual repair.
Mood notes expand to their full text in the list on desktop and touch,
replacing the hover-only tooltip, and the mood CSV export carries a
note column.
A remember action under every persisted chat message adopts its content
into the matching self-context field — allergy statements land in
allergies, diagnosis wording in conditions, everything else in the
coach focus — reusing the adopt endpoint's dedupe and encryption, with
the result visible and editable where the self-context lives. The
deterministic fact patterns accept closed filler-word and possessive
variants that previously defeated them, and stored facts survive the
snapshot budget squeeze instead of being the first block shed on
data-rich accounts.
The dashboard reserves chart-row skeleton cells from the saved layout
before the snapshot resolves, so a cold load paints close to its final
footprint instead of growing by a screen when data lands; the tile
silhouettes count only tile-capable widgets with blood pressure as two,
the greeting line keeps its box through name personalisation, and the
getting-started checklist moves below the charts where its arrival
shifts nothing. On the insights overview the targets build runs its
eight reads in parallel with a one-year floor on the glucose scan
instead of strictly in sequence over an unbounded history, the derived
batch gains a server cache where it previously recomputed sixteen
metrics on every read, both serve stale-while-revalidate, and the
health-score column reserves its exact footprint while the analytics
payload is pending.
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
Intl.DateTimeFormat instances are memoised per locale, timezone and
options signature — construction dominated the 365-day ledger build and
the hot path drops about fivefold. A batched GET /api/medications/compliance
serves every card from one request through the same per-medication cache
cells the detail page reads, replacing the per-card fan-out; the list
response stays untouched so the due line never waits on a ledger build.
The medications and compliance buckets serve stale-while-revalidate,
interactive writes evict while background bulk sync marks stale, the
per-id route defers ownership and schedule reads until a cache miss,
and the card body reserves constant slots for the status and cycle
lines so late data stops shifting the layout.
A sha256 fingerprint of the compacted feature snapshot is stored beside
every generated text; nightly, forced and ingest-triggered regeneration
all skip the model call when the fingerprint matches and refresh the
timestamp instead. Warms cover only the user's resolved locale and the
blanket per-status eviction after a comprehensive generation is gone —
a lazily-read second locale regenerates on its own miss path. The
ingest debounce widens from thirty minutes to six hours and no longer
deletes cache rows, the visit-triggered warm re-checks freshness at job
start, backs off for an hour after a failure and is capped at two
forced runs per user per day, and its dedup window now outlives the
client poll horizon. An active day drops from hundreds of model calls
to a handful tied to actual data change.
Stale-served insights responses now carry a revalidating flag; the
advisor and comprehensive queries poll on a bounded 25 s cadence until
the background warm lands, so an open session converges instead of
pinning yesterday's briefing for an hour. Interactive measurement
writes (single create, edit, delete, bulk-delete, restore) hard-evict
the analytics cache so a manually entered value is reflected on the
next read; background batch sync keeps the mark-stale path. The
visit-triggered force warm collapses to one job per user instead of
one per locale, and the umami script proxy failure now annotates a
named action with the failure reason instead of a bare flag.
The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
Prefetching the snapshot into the query cache before hydration made the
server-rendered skeleton disagree with the first client render. The
early kickoff stays, but the response now commits through the mounted
query itself, so the speedup keeps none of the mismatch. The dead
nudge-resolver leftover goes with it.
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
The manual-era boundary anchors on the medication's creation instead of
the current moment, so a backfilled era can only describe time before
the app was tracking.
The schedule tab shows its eras as a collapsible timeline — current
plan since its date, predecessors with their times and span — and a
past era can be added manually for old history, with validation against
overlaps and the live plan. Invite creation chooses one to fifty uses
and the redemption list scrolls.
Open follow-up questions now run as a guided in-chat sequence — a quiet
card offers to start, the coach asks one question at a time with
visible progress, answers can be adopted into the matching self-context
field, skipping keeps a question pending and a closing summary says
what was taken over. Proactive nudges learn three new deterministic
signals — weight drifting from its target band, a run of short nights,
an active user falling silent — address the focus the user asked to
watch, and gain per-group toggles and a frequency preference.
The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
The measurements page consumed its add parameter during render, which
crashed server rendering now that every route renders dynamically with
real search params. Compliance day buckets anchored to the current
wall-clock minus twelve hours, so before local noon the current day was
missing from every bucket series — they anchor on local noon now. The
wizard spec accepts the localized start date its summary deliberately
shows, and the generated contract picks up the version bump.
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
Deleting a measurement or mood entry can be undone from the toast via
new restore endpoints, dialogs submit on enter with busy states, silent
mutations gain error toasts, and the settings area finishes its
consolidation: provider and integration cards live in focused
subcomponents, every setting persists server-side at exactly one home,
and the save conventions are written down. The remaining raw fetches in
settings move onto the shared client.
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
An intake can carry the dose that was actually taken — editable in the
quick-add and log dialogs, shown as a quiet deviation line in the
history. The measurements and mood lists refresh by pull on touch, the
insight sub-pages gain the same customize entry as the overview, and an
answered coach follow-up offers to adopt the answer into the matching
self-context field.
Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
The reminder worker becomes a registry over twelve queue modules cut
along the existing test structure, and the dashboard page sheds its
quick-entry sheets, header and range logic into components — same
behaviour, pinned by the existing suites.
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
A dose past its slot time but still inside its catch-up window now stays
on the medication card as overdue and takeable instead of yielding to
the next occurrence — the next-due field can therefore carry a past open
slot, flagged with nextDueOverdue. Y-axis padding no longer dips below
zero for series that cannot be negative, so a steps chart stops
suggesting minus nine hundred steps.
Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
2026-06-10 22:21:44 +02:00
1205 changed files with 114501 additions and 21883 deletions
## [1.17.1] — 2026-06-15 — preventive care, lab results, and more of the data you already have
This release closes the loop from tracking to acting. Preventive-care reminders tell you when to measure or check what, structured lab results give your bloodwork a home, and a new recovery view surfaces signals that were already being collected but never shown. Sleep timelines now read in real clock times, marking a dose on one device clears its reminder on the others within seconds, and self-hosters gain a generic webhook channel, email, one-tap Web Push setup and a proper notifications guide. Every new number is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app. No breaking changes.
### Added
- **Preventive-care reminders.** Set a reminder to measure your blood pressure on a cadence or to schedule an annual blood panel, with a clear next-due date and where to do it. A matching measurement marks it done on its own; the rest you tick off. Reminders reach you over every notification channel, and the cards stay calm — status is a quiet badge, never an alarming colour.
- **Structured lab results.** Record bloodwork and biomarkers with their reference range, see each one trend over time, and carry them into the doctor-report PDF and the FHIR export. An out-of-range value is shown plainly, not in red.
- **A recovery view, and sleep quality in depth.** A new recovery page gathers strain, training load and autonomic-charge readings, and the sleep page gains an efficiency, performance and sleep-score block — metrics a connected device was already sending that nothing surfaced before. Each appears only once it has data and stays calm until it has enough to be sure.
- **Import and backdated entry.** Import a CSV of past measurements with a previewed, per-row result and unit conversion, and log an entry with a past date and time — the cold-start escape hatches for bringing existing history in.
- **Onboarding that does what it says.** A short, skippable health-baseline step, and the goals you pick now actually seed your dashboard.
- **Polar and Oura credentials in the browser.** Both connect with your own developer-app credentials entered in settings, like the other integrations — no environment file needed.
- **More ways to be notified.** A generic webhook channel reaches a self-hosted notifier or a chat service, an email channel sends over your own SMTP server, and an operator can see delivery health across every channel. Web Push keys can be generated in one click from the admin panel.
### Changed
- **Sleep reads in real clock times.** Per-stage rows now carry their own start and end, so the "last night" timeline lays out across the night instead of stacking — measured where the device reports stage timing, and an honestly-labelled reconstruction where it only reports stage totals. Nights logged by more than one source resolve to a single total.
- **A dose taken on one device clears the others in seconds.** Marking a dose sends a silent sync to your other devices, so a lock-screen reminder ends without waiting for the next app open.
- **More of a connected device's data flows in.** Readiness contributors, body-temperature deviation, blood-oxygen, a sleep score and autonomic-charge and training-load now come through where the source provides them. Body weight is never taken from a wearable strap.
- **One product across every screen.** Desktop and mobile navigation now tell the same story, the coach has a single home, the layout and reminder settings each gather under one hub, and every integration card links to its setup guide.
### Fixed
- Sleep nights from some sources were stamped at the wrong instant and sat shifted earlier in the day; corrected, with a one-time backfill that re-syncs affected nights.
- The doctor-report sleep figure now matches the dashboard and the companion app, reading the same reconstructed per-night total as every other surface.
- Polish across the new surfaces: consistent loading and empty states, design-token colours, responsive grids, and a calm confirmation for regenerating Web Push keys.
### Security
- The webhook channel pins a public host and refuses a private or loopback address unless explicitly allowed; webhook secrets, SMTP credentials and the Web Push private key are kept out of any recorded error; the key-generation and delivery-health endpoints are reachable only from an authenticated admin session, never a token.
### Self-hosting
- A notifications guide spells out that no Apple account is needed — Web Push, Telegram, ntfy, the new webhook and email all work without one — alongside a backup-and-restore callout and a clearer note on which variables must be whitelisted to reach the container.
### Operator note
- Migrations 0162–0166 are additive. A boot-time backfill re-syncs sleep nights from the affected sources to the corrected timeline once; it is idempotent and bounded.
## [1.17.0] — 2026-06-14 — clinical depth for glucose and sleep, and three new sources
This release builds new depth on the coherent foundation laid over the v1.16 line. Blood glucose gains a clinical panel, sleep gains a debt and chronotype reading, and three new data sources — Nightscout, Polar and Oura — join Withings, WHOOP, Fitbit and Apple Health. Every new metric is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app, and each holds back a confident reading behind a calm "still learning" state until it has enough data. No breaking changes.
### Added
- **Blood-glucose clinical panel.** Beneath the glucose chart, a panel reads time-in-range on the consensus bands, the glucose management indicator and an estimated A1C, and the coefficient of variation with an instability flag — with the J-index and the low/high blood-glucose risk indices behind an advanced view. It is honest about its limits: a panel built from spot readings says so, and it waits until enough readings over enough days have accrued before asserting anything.
- **Nightscout.** Connect a self-hosted Nightscout instance and its continuous glucose readings flow in — the density that makes the clinical panel meaningful. Public instances work by default; a per-connection toggle allows a private or home-network instance.
- **Sleep debt and chronotype.** The sleep page now shows a rolling sleep debt against your age-based need and an MCTQ chronotype with your mid-sleep, social jetlag and a type band — the deeper detail behind an advanced view, and the type held back until enough free-day nights are recorded.
- **Polar and Oura.** Connect either over OAuth to bring in sleep, heart rate, respiratory rate, activity and recovery. Recovery now reads from the strongest connected source — WHOOP, then Oura, then Polar, then the computed proxy.
- **Reorder-aware medication supply.** The low-stock warning now accounts for how long a refill takes, so a weekly medication is flagged before its last dose rather than after, with a concrete "runs out on … — reorder by …" date. A reorder lead time is configurable globally and per medication.
- **Invite deep-link.** Invites open through a link the companion app can intercept to start a prefilled registration, with a browser fallback.
### Changed
- **The coach reasons from the new signals.** Glucose time-in-range, sleep debt and chronotype, and a connected device's native recovery and strain now reach the coach, which prefers the device's gold-standard number over a computed proxy and respects each metric's still-learning state.
- **Mood and cycle entry is one sectioned sheet.** A quick row of your recent tags stays open, the rest folds into collapsible sections with a count of what's set inside — fast by default, deep on demand.
- **Wearable data you already had now surfaces.** Heart-rate variability from a ring or strap appears on the HRV view, and Oura and Polar steps, energy and pulse are ranked as real sources.
- **Onboarding tells the truth.** The setup flow now presents the sources that actually shipped, signposts importing existing history, and sets the expectation that some insights sharpen over the first week or two — instead of promising a configuration that did not happen.
### Fixed
- The sleep "last night" timeline bar returns for nights whose stages share an end instant, and the dashboard clears a recorded dose without a reload (carried forward from v1.16.17).
### Security
- Outbound Nightscout calls pin a public host by default with an explicit private opt-in, the stored token is kept out of any recorded error, and the new OAuth connections verify a signed, expiring state before exchanging a code.
### Operator note
- On the first nightly low-stock check after deploy, a medication already flagged low re-notifies once as the warning threshold moves to its lead-time-aware value. Expected and bounded.
## [1.16.17] — 2026-06-14 — the sleep timeline returns, and the dashboard clears after a dose
Two fixes to behaviour that surfaced in daily use. No breaking changes.
### Fixed
- **The "Last night" sleep timeline bar returns.** The staged hypnogram — which lays the night out across deep, core, REM and awake by clock time — had collapsed to just the numbers for nights whose stages share an end instant, leaving the breakdown without its bar. The bar now appears whenever the night carries timed stages; only a session recorded on a single instant falls back to the breakdown alone.
- **The dashboard clears a dose the moment it is recorded.** After recording an intake from the medication card or detail page, the dashboard kept prompting for that dose until a full page reload, because the home view was refreshed only while it was on screen. It now refreshes as soon as the dose is recorded, with no reload.
## [1.16.16] — 2026-06-14 — one engine for sleep, recovery and glucose
This release continues the work of making one number mean one thing everywhere. Sleep, recovery and blood glucose are now read from a single source on every surface — the dashboard, the coach, the doctor-report, the CSV and FHIR exports, and the companion app — so the figure you act on is the same wherever you look. Several displayed numbers become correct-but-different; each is called out below. No breaking changes.
### Added
- **Per-stage sleep on the night feed.** The sleep-night feed now rounds every duration to whole minutes and carries the per-stage breakdown (deep, core, REM, awake), so the companion app renders the same night the web does.
### Changed
- **Sleep is reconstructed once, from one engine.** The sleep score now reads each night through the same reconstruction the rest of the app uses, with source de-duplication: a night recorded by more than one source (for example a watch and a ring) is counted once instead of summed. For multi-source nights the score — and the readiness that builds on it — drops to its true figure rather than an inflated one.
- **WHOOP recovery is the recovery you see.** When a WHOOP-native recovery exists it is now the single canonical value on the tile, the chart, the doctor-report, and the coach; the computed proxy is the fallback when there is no native score. A connected account no longer sees the proxy and the native value as two competing numbers, and the night each belongs to is resolved consistently, so one night reads as one recovery. A WHOOP user's recovery figure can change to the native one.
- **Blood glucose speaks your unit everywhere.** Glucose is converted once, at the point it is served, through a single helper — so the series the companion app charts, the CSV export, the dashboard tile, the detail page, and the coach all read the unit you chose. The CSV export now matches the FHIR document instead of emitting raw mg/dL against a converted clinical export. If you read in mmol/L, these surfaces now show the converted value (for example 100 mg/dL as 5.5 mmol/L); mg/dL readers see no change. The blood-glucose median no longer claims a fixed window its value does not hold.
- **The coach reads the canonical numbers.** The coach now quotes recovery from the same resolved series as every other surface rather than a blend of sources, and states blood glucose in your unit instead of a bare mg/dL number.
- **WHOOP sync keeps a cursor per data type.** Recovery, sleep, workouts and cycles each track their own sync position, so a single slow or rejecting collection no longer holds the others back; a change notification refreshes just the affected record, and a full sync can backfill the deep history an incremental tick skips.
### Fixed
- **The coach appears when a shared model is configured.** A self-hoster who runs the coach from a server- or operator-managed model — with no personal key — now sees the coach instead of an "unconfigured" state, because the provider endpoint reports whether the coach can actually answer.
- **A consent receipt cannot double-mint.** Two requests arriving together can no longer leave two active consent grants; the active receipt is enforced unique and minted atomically, and a concurrent first grant resolves to the winning receipt instead of an error.
### Security
- **Consent endpoints are rate-limited.** The consent grant, read and revoke routes share a per-user limit, so the receipt path can no longer be driven in a tight loop.
## [1.16.15] — 2026-06-14 — an honest calendar that learns before it predicts
A focused release on the cycle calendar: it leans on observed data, names its
uncertainty plainly, and reads your charts the way the Sensiplan method does.
No breaking changes.
### Added
- **Mark a reading as disturbed.** A temperature taken after a fever, a late night, or any off day can be flagged "disturbed" and is dropped from the evaluation, so a single skewed point no longer drags the curve.
- **A positive ovulation test anchors the prediction.** When you log a positive test, it refines the predicted ovulation day — it sharpens the estimate but never overrides a temperature shift you have already confirmed.
- **Choose your secondary fertility sign.** Advanced cycle settings now let you track either cervical mucus (the default) or cervix observation alongside temperature, so the symptothermal evaluation follows the sign you actually record.
- **Warmer phase descriptions.** Each cycle phase gained a second paragraph of plain-language context, so the calendar explains what is happening, not just which phase you are in.
### Changed
- **The calendar learns before it predicts.** Below three logged cycles it shows a calm "still learning your cycle" state instead of a confident fertile-window or ovulation guess — and the same gate now applies on the API, so every client reads the same honest signal rather than painting a window the rest of the app holds back.
- **Temperature evaluation no longer under-detects ovulation.** The symptothermal reading gained the two Sensiplan exception rules — the slow rise and the single fall-back day, each confirmed on a fourth reading — so a real shift is recognised in the patterns that the simple rule used to miss.
- **The mucus peak settles before it is trusted.** A mucus peak is now confirmed only after three drier days follow it, so a stray late entry no longer shifts the peak backward.
### Fixed
- **Longer cycles are no longer clipped.** Cycles up to roughly sixty days are evaluated in full instead of being cut short, so a naturally long cycle reads correctly.
## [1.16.14] — 2026-06-14 — one number everywhere: the score, the coach, and the doctor-report agree
### Changed
- **Blood-pressure in-target speaks one window everywhere.** The in-target tile, the health-score blood-pressure pillar, and the coach now read a single 90-day window — labeled on the tile, so it is no longer hidden. The tile shows its real span ("· 23 T") until 90 days of history have accrued, and holds back a percentage ("sammelt noch Daten") until it has at least a handful of readings, so a single measurement can never imply a confident share. The all-time view stays on the detail page. The headline figure can differ from the previous unlabeled 30-day number — it is now the correct, consistent one.
### Fixed
- **The dashboard ring and the insights card show the same health score.** Both assemble the score from one shared input builder over identical windows, so the same account can no longer see two different scores or a pillar present on one surface and absent on the other; the ring's day-delta now also reflects blood-pressure movement.
- **The coach quotes the adherence the card shows.** Medication compliance routes through the same authority as every other surface instead of the coach's own per-day tally, so it can no longer reason from a number you never see — it matters most on irregular, rolling and as-needed schedules.
- **The doctor-report matches the app.** The adherence in the PDF/FHIR export is computed from the same dose history — slot attribution, cadence and cross-source dedup honoured — as the detail page, instead of a raw intake-row count, so the figure a clinician reads on paper agrees with the screen.
- **Measurement and mood entries reject implausible timestamps.** A future date beyond a small clock-skew allowance, or anything before 1900, is refused — matching the medication-intake guard — so a stray future-dated reading can no longer distort the recent-window metrics.
## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line
### Added
- **The app installs and works offline.** The service worker now registers for every visitor, not only after notifications are switched on, so the app installs to the home screen, its shell and assets are cached, and a lost connection shows an offline page instead of a dead tab. Authenticated pages are never cached — their data always loads live — the share-by-link page is kept out of the cache entirely, and signing out clears the page cache. The running app still heals itself across deploys, and the cached-version marker can no longer drift stale between releases.
### Changed
- **The default mood tags are curated.** The redundant `happy`, `excited`, water and music defaults step aside, `overtime` returns to the work picker, a new `praise` ("Lob") tag joins it, and the manual sleep-quality rating retires — a measured night already covers it. Tags already recorded against any of these keep resolving their labels; nothing on existing entries changes.
- **Page headers hold one line on a phone.** The medications header no longer drops its actions to a second row, the insights "Ask the coach" action sits to the right where it belongs, and a long label in the more-menu stays inside its tile. The mood, measurements and dashboard headers get the same treatment so none of them can wrap either.
### Fixed
- **A historical dose edit credits the schedule that was live then.** Editing a past intake on a medication whose schedule has since changed now attributes it to the dosing era valid at the dose's own time rather than today's schedule — the same era the history and compliance already read it under.
- **A CSV intake import moves the tracked stock.** It was the one path that recorded taken doses without decrementing the supply, so the runway read high; it now consumes exactly like every other path, once per dose, and a re-import never drains the shelf twice.
- **A workout that reaches WHOOP late is no longer lost.** Workout ingest used a one-hour overlap window while recovery and sleep used a far wider one, so a phone that synced to the WHOOP cloud more than an hour after a session left the workout permanently outside the window. It now shares the wider window; the idempotent upsert keeps the widening from duplicating anything.
- **Sleep night totals read as whole minutes** instead of a long fraction, matching the contract the clients decode.
- **A tiles-only layout save no longer wipes section customization.** Reordering the navigation tiles without sending the section layout keeps the stored sections instead of resetting them to defaults, and the same holds the other way around.
- **The web AI surfaces honour consent again.** The web client now records the consent receipt the server requires before any model call, so insights and the coach stop falling back to the empty no-provider state for people who have granted it, and the status reads "consent missing" honestly when it is genuinely absent.
- **The coach button in the public demo no longer errors** — it steps aside there instead of offering a send the demo blocks.
## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm
### Added
- **A dose can consume a fraction of a unit.** Split-pill medications now declare ½, ⅓ or ¼ of a tablet per dose — the wizard offers a curated set of fractions alongside the whole numbers, the most error-resistant input. The supply tracks the fractional remainder (30 tablets dosed at a half each reads 29.5 after one dose), the days-left projection follows, and undoing a dose refunds exactly what it consumed. Existing whole-number doses are untouched; the inventory unit counts widen to decimals across the API contract. (#316)
### Changed
- **The medication cards hold one shape.** The low-supply notice moves onto the adherence row beside the streak — a slot every card already reserves — so a card that is low on stock no longer pushes its bars out of line with the card beside it. The gap between the drug class and the first line tightens at the same time.
- **The dashboard and insights stay warm between visits.** The analytics and derived-insight reads keep a served-stale snapshot for an hour rather than ten minutes, so returning after a normal break paints instantly and refreshes in the background instead of paying the full cold rebuild — which a visit spaced more than ten minutes apart hit almost every time.
### Fixed
- **The medications list refreshes when you return to it.** Navigating back to the page — or reopening the supply tab — now refetches the list, compliance and stock instead of serving a client cache that could be minutes stale, so a dose logged on another device shows up without a manual reload.
## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice
### Added
- **The app says so before the pack runs out.** A daily pass projects every tracked supply against its schedule and notifies on the configured channels when the runway falls below a per-account threshold — once per crossing, re-armed by a refill, re-announced when the threshold changes. The threshold lives beside the reminder settings (1–60 days, default 7, or off), the push deep-links straight into the supply tab, the table's stock column carries the projection as "≈ X Tage", and the card states the remaining runway once it crosses the threshold — only then.
- **Mood tags get groups, icons and a home of their own.** Custom groups with encrypted labels, a searchable 75-icon picker, and a manage page under settings; tags archive without touching the entries that carry them — editing an old entry can no longer drop its archived tags either — the picker offers inline creation right where a tag is missing, custom labels finally render in the picker AND across insights, and the arrangement persists per account. The whole surface ships in the public API contract.
- **Every due dose in one confirmed action.** When two or more doses are due — including a dose escalated past its catch-up window — a button on the medications list collects them into one confirm dialog and records each through the same path as the card buttons: slot attribution, stock consumption and every dependent surface included; failures are counted, not hidden.
- **A medication can declare itself as-needed.** No schedules, never due, never reminded, excluded from every compliance rate — but intakes log normally, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice; switching it onto a fixed schedule later never repaints the schedule-less stretch as missed doses, and the doctor report lists it without inventing a rate. (#316)
- **Units-per-dose and the default pack size are editable from the supply tab** — a manufacturer switch with a different blister size no longer requires walking back through the wizard.
### Changed
- **One wrench marks every customize entry point.** Dashboard, insights and medications share the glyph, the table corners clip to their container again, and the card/table view choice moves into its own settings section.
- **The coach opens in place.** The button brings the side drawer over the page you were reading instead of navigating away, and grew to a comfortable tap size.
- **The card's dose state lives on the next-intake row.** Take-now, late and the overdue escalation speak from the line that names the dose — with their time beside them, on mobile too — instead of a separately reserved status line that left a hole on every settled card.
- A source ladder saved before a newer integration existed now lists and ranks the later-added sources everywhere — settings, charts, rollups and the sleep picker — instead of silently pinning them below every ranked one.
- WHOOP's self-reported profile weight is no longer ingested as a measurement; the stale entry stops resurfacing as the newest weight, and existing accounts heal on the next sync.
### Fixed
- **The hypnogram keeps its timeline when a summary writer joins the night.** A stage summary stamping all its rows on one instant no longer evicts a per-segment night; when only summaries exist, the card shows the stage breakdown instead of a fake skyline.
- **A dose taken on the dashboard updates the dashboard.** The snapshot band refetches with every intake path instead of waiting out its polling interval.
- The supply tab can delete a container, its controls carry the outline they always implied, each container states its condition inline next to its figures, and the table's stock warning follows the user's runway threshold instead of a leftover four-dose constant.
- Every wide event carries the recent event-loop lag, and a stalled loop emits its own warning event — the instance-level signal per-request durations cannot carry.
## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order
### Added
- **A medication can declare how many units one dose consumes.** Two 2 mg tablets for a 4 mg dose register as one dose taken and decrement two units, the supply dialog accepts quantities in doses or units with live conversion, containers carry a type (pen, ampoule, blister pack, inhaler, bottle) and hold up to 1000 units — a 200-dose inhaler canister finally fits. (#316)
- **The medication list has a table view and an order of your choosing.** A toggle beside the add button switches between cards and a compact, per-column sortable table; both views share a manual order, and the whole arrangement lives in its own settings section behind the same gear icon the dashboard and insights pages use. The choice persists per account. (#316)
### Changed
- **The daily-overview band is opt-in now.** New accounts and accounts that never chose it start without the band and keep the plain greeting; it switches on under dashboard settings.
- **Expired containers count as available nowhere.** Every surface — overview row, supply tab, list, table, GLP-1 card and the runway estimate — agrees on the same figure and shows expired stock separately instead of mixing it in.
- The wellness rings sit exactly on the theme palette now — readiness green, recovery cyan, sleep purple, stress orange, strain pink — instead of neighbouring tones, and the light theme follows the same five hue families.
- The inventory endpoints speak one wire dialect: request fields renamed to `unitsTotal` / `unitsRemaining` to match the responses they always returned.
### Fixed
- **Every taken dose now moves the tracked stock.** Only one of the six intake paths decremented the supply before — web, the status toggle, the phone sync, the external API and the Telegram button all consume now, the next container opens automatically, and undoing, skipping or deleting a dose refunds exactly what it consumed. Each dose carries its consumption stamp, so a double tap racing a sync replay decrements once, and a re-posted history never drains the shelf. Doses recorded before this release stay untouched — the count starts moving with the next dose, or correct a container once under the supply tab. (#316)
- **A dashboard full of readings no longer claims "not enough data".** While the score warms up the band says it is being computed, and a first reading of a new data type — a fresh wearable metric, a first glucose value — can no longer knock the score out, because the gate checks only the types the score actually reads.
- **The score stops flickering on busy accounts.** The pre-aggregation rebuild deleted its window before inserting the replacement without a transaction — a concurrent sync write could abort the insert and leave whole types empty until the next pass. Delete and insert run atomically now, and the boot-time backfill shares the fold horizon, so an account with years of history stops re-backfilling on every deploy.
- **The filter pills on the mood and measurements lists open their menu on screen again.** The menu opened off-viewport below the page since v1.16.1 — selecting a mood, type or source read as a dead control.
- **The coach button stops blinking out on chart hovers.** It no longer hides whenever a chart tooltip is open anywhere on the page, and it sits evenly in the corner.
- The pre-item GLP-1 supply ledger reads again: accounts that tracked pens through the older ledger see their count on the card and in the coach until they register containers, which then take over.
## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth
### Added
- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header.
### Fixed
- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed.
- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh.
- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions.
- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed.
- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one.
- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL.
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
### Added
- **The coach is one tap away on every page.** A round button sits bottom right, stays put while you scroll, shows a quiet dot when a nudge is waiting, and steps aside for the selection bar and the welcome tour.
- **Tell the coach to remember something.** A remember action under your own chat messages stores the statement in the matching self-context field — allergies land under allergies, diagnoses under conditions — visible and editable in the settings, and fed into every future conversation. Statements phrased casually are recognised again, and remembered facts no longer drop out of the prompt on data-rich accounts.
- **Mood notes in full.** Notes expand to their complete text right in the list — the toggle only appears when something is actually clipped — and the CSV export carries a note column.
- **The hypnogram names its source** and paints only the stages the night actually contains.
### Changed
- **Insight texts regenerate when your data changes, not on a timer.** Every generated text stores a fingerprint of the data it describes; nightly runs, page visits and sync bursts skip the model call when nothing moved. A notable new reading is narrated the same morning, a manual regenerate refreshes the cards along with the briefing, and texts can no longer claim freshness over readings that crossed a staleness boundary. An active day drops from hundreds of model calls to a handful.
- **Medication cards arrive in one piece.** All cards share a single compliance request instead of one each, the heavy date math runs about five times faster, and the status line, adherence bars, streak and cycle line keep reserved slots — nothing shifts when data lands. A dose logged from the phone app is reflected immediately.
- **A cold start paints the page you'll actually get.** The chart row reserves its space before any data arrives, the tile silhouettes match the real layout, and the health-score card holds its column while the numbers compute. The insights overview runs its reads in parallel and serves repeat visits from cache — the targets and derived endpoints answered in well over a second before.
- **Sleep nights pick the richest recorder.** Several apps writing through the same health platform no longer blend into one night — the writer with actual stage data wins, time in bed unions every writer's window, and nights that reach the sleep service late are fetched up to seven days back, so a wrong night heals itself on the next sync.
### Fixed
- Charts no longer strand on a skeleton after a deploy: the update check re-arms per version, a failed chart chunk retries and then degrades to a single card with a reload button, stalled requests time out and retry, and a failed data read shows an error with a retry instead of "no data in this period".
- Screen readers hear chart failures and coach nudges, the floating button leaves the tab order when it hides, focus survives the remember confirmation, and the icon cluster on insight pages no longer has overlapping touch areas.
- The duplicated customize entry and the per-chart target link are gone, show-all-values became an icon beside the target control, pill borders stop clipping in the tab strip, and the mood tag bars adopt the muted palette of the charts above them.
- Changing a target range, profile detail or source priority is reflected in the analytics on the next read, AI consent is honoured before any stored text is reused, and CSV exports neutralise spreadsheet formula prefixes in free-text cells.
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
### Changed
- **Insights always answer instantly.** A stale briefing is served immediately with a regeneration kicked off in the background — the response carries a revalidating flag and the open page polls briefly until the fresh content lands, instead of blocking the visit on up to ten seconds of inline generation.
- **The nightly pre-warm no longer skips evening readers.** The staleness threshold drops from twenty hours to one — previously an evening visit stamped the cache fresh, the night run skipped it, and it expired exactly the next evening, so the people who read insights after work were the ones who always hit cold generation. The visit-triggered warm fills both locale families and collapses to one job per user.
- **The session resolves faster.** The profile read inside `/api/auth/me` runs in parallel with the cookie refresh, the bug-report status answer comes from a ten-minute cache without a write per request, and the medications page is prefetched the moment its nav entry is touched.
- **The insights route ships less JavaScript.** The schema validator loads lazily and the mood charts split out of the main chunk, trimming the cold download that dominated an uncached first visit.
### Fixed
- A manually entered measurement is reflected in the analytics aggregates on the next read — interactive writes evict the cache instead of marking it stale, while background sync keeps the cheaper path.
- The analytics script proxy degrades to a silent no-op instead of failing every page when the upstream is unreachable, and records the failure reason where the operator can find it.
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
### Added
- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation.
- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works.
### Changed
- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree.
- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question.
- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner.
### Fixed
- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due.
- The adherence bars keep a fixed geometry in every state instead of growing with their status text.
- The remaining hydration warnings on the dashboard, insights and AI settings are gone.
## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past
### Added
- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over.
- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history.
- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch.
- **Invites mint up to fifty uses** with a scrolling redemption list.
### Changed
- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call.
### Fixed
- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact.
## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record
### Added
- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama.
- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history.
- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast.
- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page.
- **Answered coach follow-ups can flow into your self-context** with one tap.
### Changed
- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second.
- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes.
- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links.
- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts.
### Fixed
- Server and first client render now agree everywhere query state used to leak into the chrome.
- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong.
- A language switch updates pending status notes and only flips once the new language is actually loaded.
## [1.16.3] — 2026-06-10 — schedules remember their history
### Fixed
- **Changing a schedule no longer rewrites the past.** Every material cadence change now archives the outgoing schedule as a dated revision, and history, compliance and write attribution evaluate each period against the schedule that was valid at the time — a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistently dated old anchors behind an explicit flag.
- **The welcome tour can no longer strand its buttons below the fold.** The target scrolls into view first, the popover flips above it when space runs out, and a centred sheet takes over on any viewport when neither fits.
- The avatar menu drops its about entry; the section lives in settings.
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
<img width="2316" height="1207" alt="HealthLog dashboard: health metric tiles, trend charts, and medication overview" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
## Why HealthLog
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
Try the [live demo](https://demo.healthlog.dev) to see a working install.
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
HealthLog delivers reminders (medication doses, mood check-ins, personal
records) and a few operational alerts through a small cascade of push
channels. The single message to take away: **you do not need an Apple
Developer account or APNs to get push.** Three of the four channels are
free, work in the browser or in a messaging app you already have, and
cover every platform a self-hoster runs. APNs is only for the optional
native iOS app.
When an event fires, the dispatcher fans it out to every channel a user
has enabled, in a fixed order — APNs first, then Telegram, then ntfy,
then Web Push as the universal fallback
(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and
independent: a user can enable one, two, or all four, and a failure on
one never blocks the others.
## TL;DR — which channel needs what
| Channel | What it needs | Cost | Where it works |
| --- | --- | --- | --- |
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
Web Push is the recommended default — it needs no third-party account,
just a keypair you generate yourself once.
## Web Push (recommended default)
Web Push delivers notifications straight to a browser — desktop or
mobile — even when the HealthLog tab is closed, as long as the browser
or installed PWA is alive. It is the broadest-reach channel and the one
to set up first.
### 1. Generate and store a VAPID keypair
VAPID is the signing scheme that lets a push service trust your server.
You also choose a **subject** — a `mailto:` address the push service can
contact about your sender, e.g. `mailto:you@example.com`.
The server loads VAPID config from the database first and falls back to
environment variables (`src/lib/notifications/vapid-config.ts`). The admin
panel is the easiest path because it survives without touching `.env`.
**Admin panel — Generate keys (easiest).** Sign in as the admin user,
open `/admin`, and find the **Web Push VAPID** card
APNs is **all-or-none**: either set the three IDs plus one key source, or
leave the whole block unset. A partial config is rejected so the channel
never silently half-works (`scripts/check-env.ts`). When more than one key
source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` >
`APNS_KEY_FILE`.
Two gotchas worth knowing before you blame a stale token:
- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file
pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body,
base64-encoded) sidesteps that entirely and is the recommended source.
- **Scope the key "Sandbox & Production".** In the Apple Developer
Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails
on production (TestFlight / App Store) device tokens with
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
> its own local medication reminders and ask the server to stop sending
> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
> a web/PWA-only self-hoster never needs it and there is no toggle for it
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Which channel should I pick?
| Your setup | Recommended channel |
| --- | --- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. |
| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
Mixing channels is fine and common — e.g. Web Push on the laptop and
ntfy on the phone. Each user configures their own set from
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
},
schema:[],
messages:{
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
// from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
"description":"Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.",
"required":false,
"allOrNone":true,
"variables":[
{
"name":"POLAR_CLIENT_ID",
"purpose":"Polar AccessLink OAuth client id."
},
{
"name":"POLAR_CLIENT_SECRET",
"purpose":"Polar AccessLink OAuth client secret."
}
]
},
{
"name":"Oura OAuth",
"description":"Required to enable the Oura Cloud v2 integration. App boots without these but the Oura connect button stays disabled. Register an app at https://cloud.ouraring.com/oauth/applications and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/oura/callback.",
"required":false,
"allOrNone":true,
"variables":[
{
"name":"OURA_CLIENT_ID",
"purpose":"Oura Cloud OAuth client id."
},
{
"name":"OURA_CLIENT_SECRET",
"purpose":"Oura Cloud OAuth client secret."
}
]
},
{
"name":"APNs (iOS push)",
"description":"All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
@@ -105,6 +137,26 @@
}
]
},
{
"name":"SMTP (email channel)",
"description":"Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.",
"required":false,
"allOrNone":true,
"variables":[
{
"name":"SMTP_HOST",
"purpose":"SMTP server hostname, e.g. smtp.example.com."
},
{
"name":"SMTP_PORT",
"purpose":"SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)."
},
{
"name":"SMTP_FROM",
"purpose":"From address for outbound mail, e.g. \"HealthLog <noreply@example.com>\"."
}
]
},
{
"name":"Off-host backups",
"description":"Required ALL-OR-NONE: any of these without the rest disables backups silently.",
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
);
}
fail(
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString()??""}`,
);
}
console.log(`cosign signature verified for ${ref}`);
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
Loaded 100 of 1205 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.