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