Release v1.17.1 — preventive care, lab results, and more of your data#334
Merged
Conversation
… present 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.
…ers, lab results
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.
…, lab results, LA push token
WHOOP's v2 API returns only per-stage sleep duration totals — no onset timestamps, no hypnogram endpoint. The mapper stamped all five stage totals on the one sleep-END instant, so the hypnogram reconstructed every stage as a span touching the night's right edge: bars stacked right-aligned with no clock times whenever WHOOP was the sole source. Lay the asleep stages back-to-back from sleep onset in a fixed physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one timed row per segment with measuredAt at that segment's end and a distinct indexed externalId. IN_BED stays a single envelope row over the whole window. The existing reader and renderer are untouched — they now see distinct per-segment instants and place each block at its own time. The order is synthesised, not measured, so every reconstructed row is flagged and the night DTO carries a per-session `reconstructed` boolean (GET /api/sleep/night, regenerated OpenAPI). The client renders the hypnogram but labels it an approximate layout and never recomputes; real-series sources (Apple Health, Withings, Fitbit) stay false.
The 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.
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.
…stamp fix 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.
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 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.
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).
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.
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 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.
…backdating 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).
…up + instant bounds
…care reminders 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).
…inders
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 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.
…ed gate 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 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 /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.
…d neutral cards # Conflicts: # docs/api/openapi.yaml # src/lib/openapi/routes/index.ts
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.
…n list 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.
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 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 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.
…eton primitives 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 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.
…primitives 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.
…e model 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 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.
…ponsive grid, counters
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 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 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 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 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.
…sers 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.
…p parity, VAPID dialog, redaction
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.
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.
…on, OAuth returns, status envelope
…he data you already have
…minders table 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.
…ifting measuredAt 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.
…dths, not bit-for-bit 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v1.17.1 — preventive care, lab results, and more of the data you already have.
Closes the loop from tracking to acting: preventive-care reminders, structured lab results, a recovery view that surfaces already-collected signals, real clock-time sleep timelines, cross-device dose sync, and self-hoster notification breadth (webhook + SMTP + one-tap Web Push). No breaking changes; migrations 0162–0166 additive.
Highlights
Quality
typecheck 0 · lint clean · knip clean · openapi in sync · 10,113 unit tests green. Grand-QA across 11 lenses (colour/tile/toggle/nav/integration · medical/security/parity/architecture/file-size/dead-code/performance/UX/self-hoster): 0 Critical, all High + Medium reconciled. Deferred to v1.17.1+: reminder-worker split, lab↔reminder auto-resolve, recovery-bucketing for far-negative-UTC.