The generic metric-status generator read SLEEP_DURATION row-per-stage and
fed the snapshot a single sleep STAGE (~16 min) as the current value, so the
sleep prose reported a fraction of an hour for a full night. Route the
SLEEP_DURATION status/snapshot through reconstructSleepNights — the same
per-night, source-deduped, midnight-aware time-asleep total the dashboard,
series, and targets surfaces use — so the snapshot's series, summary, and
latest value are all the night total in minutes (the registry unit and normal
range are already minutes). Gated to SLEEP_DURATION; no other metric's status
path changes.
The readiness wellness index could surface a contributor value of 0 for an
account that recorded resting heart rate / HRV / respiratory rate but had no
scorable baseline: a non-finite baseline center or today value fell through
clamp100 to a misleading 0 that reads as a real worst-case sub-score. Return
null instead so the client drops the contributor, and document the null-vs-0
contract: null means no usable signal (no baseline, no recent reading,
non-finite input); a finite value — including a genuine 0 — means the baseline
is established and the latest reading really deviates that far.
(cherry picked from commit 0422ac012765f30069a41f662c2b025197ed9a88)
Make every metric subpage identical by construction in the shared
scaffold rather than per-page bespoke.
- Target tile matches the other cards: dark `bg-card` background and the
`rounded-xl border p-4` spacing the assessment and stat-strip cards
use, instead of the old translucent `bg-card/20` panel.
- Drop the 7T/30T/90T/1J range row and its "+X% vs previous" delta from
every subpage. The chart owns its own window via its in-card range
tabs, so the chart query is unaffected. Retire the now-dead
`MetricRangeControls`, `MetricRangeDelta`, `TimeRangePills`,
`useAnalyticsRange`, the `useInsightsRangePref` persisted-range hook,
and `sentimentFromMetricDirection` along with their tests.
- Fix the spine order to intro -> chart -> target card -> assessment ->
Min/Max/Median/Mittelwert stat strip. The stat strip moves to the foot
of the spine, below the assessment. Remove the "Letzte Messung" card
and the headline primary tile from subpages; retire
`MetricLastMeasurementCard` and `MetricPrimaryTile`.
- Blood pressure gains the stat strip it was missing: two stacked
systolic / diastolic strips, since a single-series strip would drop the
diastolic half. `MetricStatStrip` takes an optional series caption.
Update the spine guard to assert the target card precedes the
assessment and that the retired range row and last-measurement card
appear on no subpage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 80b2a810314b4c0951c0d68c04bffc89003005d6)
Reorder the Insights overview top → bottom to Hero + Coach, Wellnesswerte,
Vitalwerte, Trends, period retrospective, then today's signals — the
wellness-score strip and vitals grid already render in that sequence inside
the vitals dashboard, so the change is the surrounding section order plus
moving the signal cards below the retrospective.
Make the steps reference value in the daily briefing tappable: it was the one
key finding with no destination, so it now routes to the generic readings view
for its measurement type like every other metric drills in.
Paint the health-score headline number through the semantic success / warning /
destructive tokens instead of the raw dracula primitives, which carry no
light-mode override and read illegibly on the Alucard light theme.
Widen the wellness-score strip so the tiles always fill the row: the fixed
five-column desktop track left an empty cell on the common four-score case and
read as a missing tile.
Drop the duplicate footer "prepare assessments" control — the tab-strip
regenerate button is the single affordance — and remove the now-dead warm hook
and its strings.
Trim the coach suggested prompts to the two that read as a clear next step
(ask the doctor, is the medication working) and remove the three speculative
data-specific openers, across all six locales.
(cherry picked from commit e819c9c12777f76b66b5b466b8d159b26f169868)
Lay the narrative takeaways two-up instead of a single full-width column so
short one-liners (best daypart, logging streak, weekday peak) stop stacking
into a long ladder of half-empty rows.
Fold the low-density footnotes behind a focusable ring/info explainer icon
with a tooltip: the per-pair paired-day/r/q stat under each discovered
relation, the per-card paired-day/r line under each mood-correlation scatter,
and the false-discovery footer + observational disclaimer that sat as two
full-width rows under the discovered-relations list. The trigger is a real
button carrying an aria-label so the explanation is keyboard- and
screen-reader-reachable, not hover-only.
Charts stay Recharts and visually identical — the mood distribution and
mood-by-time-of-day charts already share one Dracula band palette
(red/orange/yellow/green over the same thresholds, --dracula-fg ticks,
--dracula-comment axes, --card/--border tooltips), so the surface palette is
already consistent and left untouched.
New explainer keys land in all six locales.
(cherry picked from commit afc38491ebfae3beed6476a79439d2b87f33f823)
The medication cards show a single actionable dose — the open/overdue
window, or the server's next-due slot. The take / skip button POSTed
`{ skipped }` only, so the route defaulted `scheduledFor` to `now()` and
snapped to whichever slot was nearest. For a twice-daily 07:00 / 19:00
medication a morning tap (before the 13:00 midpoint) recorded the 07:00
dose, and a non-deliberate morning tap mis-recorded the morning dose.
Thread the displayed dose's canonical slot instant onto the POST so the
server resolves the exact dose the user is looking at, independent of the
wall-clock. The card derives the instant from its current-window status
(the window's start time today) or the next-due instant; a PRN dose with
no slot omits `scheduledFor` and keeps the legacy now-snap path. The route
already accepts `scheduledFor` and snaps it through the canonical slot
resolver, so no server change is needed — the cards now always send it.
Add a unit suite for the slot resolver and the POST body, and an
integration test proving a morning `scheduledFor` records the 07:00 slot
and an evening one records 19:00, driven by the supplied instant rather
than the clock, with marking one dose never marking the other.
(cherry picked from commit 438832cd965574da1b5dabf6567f7bffa26e36f3)
The per-metric assessment prompt (base-system) and the scope-hardened
insight system prompt (insight-generator) each carried EN and DE as two
parallel template literals kept in sync by hand — a standing drift risk
flagged in the AI-quality audit.
Model each prompt as one section skeleton: a single ordered list where
every section supplies its EN and DE fragment side by side, and a builder
joins the fragments with a blank line. The section order and prose are
unchanged; the DE calibration is preserved verbatim. A section edited in
one locale but not the other now shows up as an asymmetric edit in one
list instead of two literals silently diverging.
Pure refactor — every emitted prompt string is byte-identical for both
locales across every metric, surface and reference combination. A new
locale-skeleton-parity test pins the structure (each section carries both
fragments, composed prompt equals the joined fragments); the existing
grounding, filler-ban, forbidden-phrase, refusal and exact-phrasing guards
pass unchanged.
(cherry picked from commit 12cd718d6d602a48cafc14ad06d82282fdaacb27)
Two connect-in-app enhancements for the native client.
return_scheme: GET /api/whoop/connect accepts an optional custom scheme,
threaded through the server-side OAuth-state row so it survives the round
trip without ever riding in the URL or cookie. When a valid scheme is
present, the callback's final redirect targets <scheme>://whoop?whoop=
connected|error&reason=... so an in-app web session auto-completes on its
custom-scheme match; absent or invalid, the web settings redirect is
unchanged. Schemes validate against a strict allowlist (custom app scheme
only, ^[a-z][a-z0-9.+-]*$, http/https/javascript/data/file/vbscript
rejected, pinned to dev.healthlog.app). A rejected scheme falls back to
the web redirect and never reflects an arbitrary value.
Bearer connect ticket: a purely Bearer-authenticated client holds no web
session cookie, so it cannot start the handshake through the cookie-gated
connect route. POST /api/whoop/connect/ticket (Bearer) mints a one-time,
~60s, opaque ticket bound to the user; only its HMAC-SHA256 hash is stored
(the raw value never persists). GET /api/whoop/connect?ticket=<opaque>
accepts the ticket in lieu of a cookie, consumes it atomically (single-use,
reuse rejected), sets the nonce cookie, and 302s to WHOOP exactly as the
cookie path. Expired, consumed, or invalid tickets return a typed 401. The
two combine. The daily WHOOP OAuth-state cleanup also sweeps expired ticket
rows.
The ticket query param is added to the egress redaction denylist so the
opaque value never reaches logs or error reports.
Migration 0124 adds whoop_connect_tickets (id, user_id FK cascade,
token_hash unique, expires_at, consumed_at, created_at) and a nullable
return_scheme column on whoop_oauth_states.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 221b362011d52dda314139e0be7cc195c47d016e)
The native client SPKI-pins the server's TLS leaf certificate. When the
leaf auto-renews (GTS re-issues it roughly every 90 days) the leaf keypair
— and so the SPKI pin — changes, and a client shipped only the old pin
refuses to connect on the next renewal: a silent outage. Nothing
server-side signalled that a client pins the served leaf.
Add a pg-boss monitor that probes the served leaf every 6 hours, computes
its iOS SPKI pin (base64(sha256(DER subjectPublicKeyInfo)) via a raw TLS
socket + X509Certificate), and compares it against the operator's
known-good set in TLS_LEAF_SPKI_PINS (comma-separated for the dual-pin
renewal window). On a served pin outside the set it fails loud: a
tls.pin.leaf_changed wide-event annotation, a system.tls.pin_changed audit
row, and a high-priority SYSTEM_ALERT fanned out to admins through the
existing dispatcher idiom. Transient TLS/network failures annotate
tls.pin.probe_failed and never alarm.
The baseline is an env var, not a persisted last-seen row — a self-learning
baseline would silently adopt the first rotation and suppress the alarm the
pinned client needs; an unset baseline logs the served pin and warns rather
than auto-adopting. The runbook documents extraction, the re-pin + dual-pin
procedure (≥11 days before expiry, ship to TestFlight), and the assumption
that the probed host is the host the client connects to.
Wires the queue into the worker (allQueues + cron + worker), adds the
optional env to the manifest, compose whitelist, and .env example, and
covers the SPKI computation + change detection with a fixture-cert unit
test plus a queue-registration guard.
(cherry picked from commit cdb88cd77b88aec71ad2f21caf2b15c185dfc01e)
Lift the take / skip intake flow — the optimistic spinner, the
failure toast, the soft-delete Undo, and the dependent-key
invalidation — into a shared `useMedicationIntake` hook consumed by
both the generic medication card and the GLP-1 card. The GLP-1 card
previously inlined its own copy that never gained the failure toast
or the Undo affordance, so a failed POST was swallowed silently and a
misclicked dose had no quick reversal. Both cards now behave
identically by construction; each keeps only its post-success
injection-site prompt.
Converge the medication status-colour surface onto the semantic
feedback vocabulary. The status pill reads as a success → warning →
destructive urgency ramp (dropping the lone `text-dracula-yellow`
middle tier), the streak flame moves off the raw `text-dracula-orange`
palette token onto `text-warning`, the detail status dot swaps its raw
`hsl(var(--…))` form for `bg-success` / `bg-warning`, and the import
result line drops `text-dracula-green` for `text-success`. Each
semantic token aliases the same colour the raw form resolved to, so
the hue is unchanged in dark mode and lands AA-safe on the light card.
Route the compliance percentages through the locale number formatter
with rounding so a non-integer rate never leaks raw, and give the
medications Add button the same `min-h-11 sm:min-h-9` tap-target floor
the dashboard Add button carries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a4415d706154b063a8783390e35eded5dbc57910)
The per-metric AI assessment was named four ways across the bundle
(Einschätzung / Auswertung / Analyse / Bewertung in German, with parallel
drift in the other locales). Sweep every assessment-state value — the
no-provider, no-result, start / refresh / re-run, regenerate and warm
strings — onto the one noun the card title already carries, in all six
locales. Keys are unchanged; only the human strings unify.
Restore the canonical metric-detail spine on the two bespoke pages that had
drifted: the mood page rendered the assessment directly under the first
chart with the target summary and breakdown sections below it, and the
medications page rendered the therapy timeline after the assessment. Both
now end on the assessment block, matching weight / bmi / pulse /
blood-pressure and the generic scaffold. A source-level guard test asserts
the assessment card is the last child of every bespoke page's shell so the
order cannot silently regress again.
Consolidate the repeated eight-prop assessment wiring: the five
useInsightStatus-backed pages now mount a shared SlugInsightStatusCard seam
(the sibling of MetricStatusCard for the generic route) instead of
hand-wiring the card and the status?.x defaults inline. The medications page
keeps its inline card because its route carries a richer summary-shaped
envelope than the standard text-only generators. The assessment footer's
"updated" caption now uses the shared relative-time formatter the briefing,
hero, last-measurement and coach-history captions use, so adjacent cards no
longer read one relative and one absolute timestamp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2cd73ce5d6cdca63fea0829990fac2a7e24afebf)
The settings cards now read connection state from the consolidated
/api/integrations/status envelope (the per-provider status routes were dropped
from the section), so the mobile-layout fixture must supply the per-integration
connected/configured fields the pills key on.
The medication NL-extraction route egresses user-typed free text (PHI) to the
operator's server-managed provider key; require an active consent receipt for
the coach surface before the chain runs, matching the other server-managed
egress sites.
Holistic consistency pass over the settings and integration surfaces so the
same concept reads and behaves the same way everywhere.
- Card headers: migrate the four integration cards (Withings, WHOOP,
Fitbit/Google Health, Mood Log) plus the profile, passkeys, avatar, about,
api-endpoints, api-tokens, and sharing cards onto the shared
SettingsCardHeader primitive. Extend the primitive with an inline title
accessory slot (Fitbit tag + experimental badge) and a multi-line
description slot (overlap / experimental / deprecation sub-notes), keeping
the rendered result identical.
- Integration verbs: normalise Mood Log onto the canonical "Sync now" /
"Sync all data" / "Synchronize" set used by the other integrations, across
all six locales. Swap the full-sync icon from Download to RefreshCw and
align its action-row icon metrics with the sibling cards.
- Test buttons: migrate Telegram and ntfy off their hand-rolled "Test
message" buttons onto the shared TestConnectionButton, so every channel and
integration test control shares one icon, label, latency readout, and error
taxonomy. Retire the now-unused testMessage / testSent strings.
- ntfy auth-token placeholder no longer reuses the "Saved" status string;
it now reads "Saved — enter new to replace" like the other secret fields.
- Save-success copy: collapse the moodLog and Telegram bespoke strings onto
the shared settings.saved string.
- Tap targets: floor every size="sm" action-row button (sync / full-sync /
test / disconnect / save / connect-secret copy) at min-h-11, fixed at the
TestConnectionButton source and across the cards, so they clear the 44px
touch minimum.
- Contrast: the onboarding source-card badge moves from muted-on-muted to
text-foreground; web-push card body rhythm aligns on space-y-4; Mood Log's
deprecation note drops its bespoke 11px-italic treatment for the shared
sub-note style.
(cherry picked from commit 11931d14b7875b7e2184a3b373d7a34b842f8eae)
Migration 0122 added MoodEntry.externalId plus the NULL-distinct
@@unique([userId, source, externalId]) and wired the moodLog webhook
and sync importer onto it, but the iOS-facing native routes never used
it: POST /api/mood-entries dropped externalId entirely (the schema did
not declare it) and POST /api/mood-entries/bulk accepted it in its Zod
schema yet parsed-and-ignored it, still upserting on the legacy
(userId, date, moodLoggedAt) key. The dedup index existed but nothing
on the native write path reached it, so the idempotent re-import the
client needs was not functional.
Declare externalId on createMoodEntrySchema (bounded to match the bulk
schema), persist it, and — when present — upsert the single-entry write
on (userId, source, externalId) so a re-post with the same id updates
the existing row in place instead of 409-ing or minting a duplicate.
When absent, keep the legacy first-write create and its 409-on-conflict
behaviour untouched. On the bulk path, branch the per-entry probe and
upsert onto the same compound key when an entry carries an externalId,
refreshing date and moodLoggedAt in the update so a re-zoned re-import
lands the corrected wall-clock on the same row; absent ids keep the
legacy wall-clock key. Both paths resolve source once so the dedup key
and the row write agree. Echo externalId back on the single response
(via the row) and on each bulk per-entry result so the client can map
server ids onto its local rows.
Cover the present-vs-absent upsert key, cross-user isolation, and the
echo with unit tests on both routes plus a real-Postgres integration
test proving a re-posted externalId resolves to one row updated in
place, a null externalId keeps wall-clock behaviour, and the same id
under two users stays isolated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6a65de22bc436daeeb73dd2a1cb612336c74a928)
Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api/<provider>/status queries. Extend the consolidated endpoint to carry
every field the four cards render (Withings activity scope, WHOOP/Fitbit
backfill state, moodLog webhook secret + entry count) and drive all four
cards off the single envelope. The legacy per-provider routes stay for the
iOS/test callers; the web cards no longer hit them.
Mood-insights cache was a plain 60 s TTL that was hard-evicted on every
mood write, so an active logger re-paid the multi-second cold compute on
every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr
+ a markStaleByPrefix that collapses the fresh TTL without dropping the
value), give the moodInsights bucket a 10-minute stale window, route the
read through cachedSwr, and switch the mood-write invalidation from a hard
evict to a mark-stale so the prior aggregate serves immediately while a
single background recompute warms a fresh one.
Defer the eager Recharts on the mood-insights surface. The trajectory
forecast card pulled Recharts into the initial chunk of every
trajectory-eligible metric page though the main chart is already deferred;
split its fan band into trajectory-fan.tsx behind next/dynamic. The three
below-fold mood mini-charts (distribution / weekday / time-of-day) were
static imports under an already-dynamic hero line chart; defer them with
next/dynamic. Each loader paints a skeleton sized to the chart's own band
so the chunk arrives without a layout shift; the charts stay Recharts and
visually identical.
(cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1)
The per-metric assessment cards are tightly grounded but their form had
converged: every card opened the same way, forced a "one doable step"
even when nothing was actionable, and a metric that stayed steady for
weeks produced near-identical paragraphs. Squeeze more value from data
already computed server-side, without touching the grounding floor or any
safety contract.
- Surface FDR-surviving cross-metric correlations on the per-metric card.
The discovery engine already screens behaviour×outcome pairs with a real
Pearson + exact p-value + Benjamini-Hochberg FDR control; that
intelligence only reached the period narrative. A new read-only consumer
runs the same full-matrix discovery and filters to the pairs that
involve the current metric, then feeds the engine's own conservative,
descriptive, never-causal interpretation strings into the prompt as
grounded relations. Best-effort: a correlation hiccup can never block the
generation it only decorates.
- Streak-aware repetition. Derive a steady-run length from the graded
series (consecutive recent weeks within a band of the user's own
baseline) and tell the model when it has already reported "no material
change" N times running, so it acknowledges the continuity in one clause
and pivots to a different facet instead of restating the same level.
- Stop forcing a step; add controlled variety. The closing step is now
conditional — when nothing is genuinely actionable the model affirms and
names one thing to watch rather than manufacturing the filler that lets
platitudes back in. A deterministic per-render variety token (seeded from
user + metric + day, never Math.random / Date.now) rotates the opening
angle so consecutive cards and days don't read identically, and the
phrasing temperature lifts 0.3 → 0.45 while the facts stay pinned by the
snapshot and the forbidden-phrase guards.
- Surface the computed data strength (n + recency) into the prose prompt so
it hedges on thin data instead of guessing what "few" means, matching the
UI confidence badge.
- Add locale-matched few-shot examples (a grounded assessment + a labelled
banned-filler counter-example) to the assessment system prompt, which
most helps weaker local-provider models follow the contract.
The own-baseline grounding, computed-not-hallucinated stats, schema-
enforced output, filler-phrase ban, and correlation-as-association
discipline are all preserved and re-pinned by new guard tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9d04cab1523e0e57a3f50c87c9f88684b01b26f)
The tag × metric crosstab summed cumulative metrics (steps, active
energy, sleep duration) per day across every source with no canonical
pick, so once two sources reported the same day — Fitbit + Apple steps,
Fitbit + WHOOP sleep — the per-day total double-counted and inflated the
with/without averages and the Welch delta the surface ranks on. Run the
metric rows through the same per-day source-priority + device-type picker
the analytics steps/sleep path uses before bucketing, keyed by the
metric's priority ladder and the user's Berlin calendar day. Same-source
per-stage rows still sum into the night total; only cross-source twins
collapse. Thread the user's source-priority blob through the read so the
pick honours the configured ladder.
The cross-metric read was a flat `take: 5000` over the 365-day window
ordered `measuredAt desc`, unioned across per-stage sleep + per-sample
pulse/HRV; a multi-source, high-frequency user blew past it and silently
dropped the oldest months, biasing the crosstab and the long-window
correlations toward recent data. Lift the cap to cover the realistic
worst case for the window and annotate a wide event when the cap is hit
so a truncated read is observable rather than silently wrong. The read
stays raw rather than moving to the DAY rollup tier because that tier
buckets on UTC midnight while these aggregates key on the user's Berlin
day, which would skew the day pairing at the boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit aa72ffba6e3bcac9f97be4adc26d689d4f352e98)
Migration 0121 replaces the MedicationIntakeEvent unique constraint with a
partial unique index `WHERE deleted_at IS NULL`. A slot the user previously
deleted (tombstoned) no longer occupies the unique slot, so a re-take
re-creates the row cleanly instead of P2002-ing against the tombstone and
500-ing. The migration dedups any pre-existing duplicate live rows first
(keep newest, tombstone the rest) so the unique build can't fail on real
data.
Measurement and Workout deliberately stay full uniques: Measurement's
compound-key writes use prisma.upsert, which Prisma 7 compiles to native
INSERT ... ON CONFLICT and Postgres cannot arbiter against a partial unique
(and consolidate-daily-mean intentionally resurrects a tombstoned canonical
row, which needs the full unique); Workout has no deleted_at column. The
rationale is documented in the migration header and the schema comments.
Migration 0122 adds MoodEntry.externalId plus a NULL-distinct
`@@unique([userId, source, externalId])`. The moodLog webhook, the pull sync,
and the JSON import now carry the source's stable id into externalId and
upsert on the new key when present, so a re-import is idempotent even when
moodLoggedAt re-rounds or re-zones. Native/MANUAL entries keep their NULL
externalId and stay on the legacy (userId, date, moodLoggedAt) path.
Migration 0123 adds AuditLog(userId, action, createdAt desc) and
MoodEntry(userId, moodLoggedAt) composite indexes for the status-cache and
mood-insights read paths.
Also corrects the stale "v1.6.0 drops this column" note on
MedicationSchedule.daysOfWeek (the column is still dual-written and read by
live paths, so it is retained) and documents the UTC-only timezone storage
invariant in a schema header comment.
Integration tests cover the intake re-take after delete (no resurrection /
no 500) and mood re-import idempotency (with and without an external id) on
real Postgres.
(cherry picked from commit 9a6723f469bdec063a99cd08df132e07ec3c95a5)
Close the launch-blocking gap where the ConsentReceipt infrastructure was
built but never enforced as a precondition: a direct API caller with a valid
token could forward a health snapshot to a server-managed external LLM with no
receipt on file. Consent was enforced client-side only.
Add a consent guard (src/lib/ai/consent-guard.ts) and call it before the first
external-provider call on the server-managed path. The gate fires only when the
resolved chain could egress via the operator's global key (admin-openai); a
user's own BYOK key (openai/anthropic), their own ChatGPT OAuth account, and
the self-hosted local provider stay ungated — that egress is the user's own act
and the settings flow is its consent. The check fails closed: a BYOK-primary
chain with an admin-openai fallback still requires a receipt, since the runner
may cascade to the operator key. The surface maps to the consent kind (coach ->
ai_coach, insights -> ai_insights_only; master ai_full satisfies either).
- Interactive routes (insights/chat, insights/generate POST) throw
ConsentRequiredError; api-handler renders a 403 with
meta.errorCode = "consent.ai.required", mirroring assistant.disabled.* so
the client can render an inline grant-consent notice.
- Off-request pipelines fail closed quietly: comprehensive generation returns a
typed skipped:no-consent outcome, and the per-metric status family
(runStatusCompletion, gating every status card + the period-narrative warm +
the off-budget Coach memory workers) surfaces the no-key fallback instead of
egressing.
Also rate-limit POST /api/fitbit/sync (the one Fitbit route without a limiter):
a 5/60s baseline plus a tighter 1/hour bucket on the expensive fullSync walk,
keyed by user id, matching the sibling Fitbit routes.
Harden the Fitbit OAuth redirect_uri: assert it is an absolute https origin (or
http on localhost), lands on /api/fitbit/callback, and stays same-origin with
NEXT_PUBLIC_APP_URL — defence-in-depth behind Google's registered-URI check so
a Host-coerced or misconfigured app URL cannot redirect the authorization code
off-origin.
(cherry picked from commit 46b5fc2cea1b6e80bee0445bfdacf5790442c67d)
Three hardening + performance fixes to the Fitbit/Google Health sync.
Stop soft-delete resurrection. The measurement write probed the existing
row by the `(userId, type, source, externalId)` unique key without excluding
soft-deleted rows, so a reading the user deleted came back on the next hourly
sync (the upsert matched the tombstone and took the update branch — silently
undoing the delete). The write now probes only live rows (`deletedAt: null`)
and treats a tombstone as absent, minting a fresh insert instead. The fresh
insert relies on the partial unique index over `deleted_at IS NULL` to keep
live-row uniqueness; the tombstone sits outside it, and `skipDuplicates` guards
a live-row race.
Batch the backfill writes + collapse the rollup recompute. The write loop
was one upsert per reading (N+1) followed by a per-(type,day) DAY-rollup
recompute, so a multi-year backfill paid thousands of serial round-trips on a
concurrency-1 worker. The existence probe is now a single `findMany` over the
batch; fresh rows go through chunked `createMany`; only already-live rows take a
per-row update for their differing values. On a `fullSync` the per-write rollup
hook is deferred and the touched type-days collapse into one
`recomputeUserRollups(from, to)` pass at the end of the cycle. The incremental
path keeps the inline per-day hook. The `stats:` overwrite contract, the
single-watermark `markSynced`, and the all-403 soft-skip guard are unchanged.
Bound the hourly poll concurrency. The poll synced every connection in one
serial loop, so a single slow Google response stalled the whole cohort. The
cohort now fans out through a bounded `p-limit` pool with per-user error
isolation, extracted into `runFitbitPollCohort` so the contract is unit testable
without exporting worker internals. The accumulators are read after the await to
avoid a compound-assignment lost-update across overlapping pool tasks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8369af2471fb8da04a938b5a784433318d81a248)
Add a table of contents to the README, extend the comparison matrix with
WHOOP and Google Health/Fitbit columns plus rows for multi-source dedup,
provider aggregation, and encryption at rest, and add a CI status badge.
Reconcile two stale claims against the current code: the Prisma model
count (now 60) and the AI architecture note (add Codex). Strengthen the
Security and Privacy section with the bring-your-own-key / local-endpoint
AI posture.
Add .github/FUNDING.yml so the GitHub Sponsor button renders, and fix the
contributor prerequisite to Node.js 22 to match the Dockerfile base image.
(cherry picked from commit 7cfe326709814dd0ce16d1554d5afd5933de448c)
Harden the native auth-token surface against four confirmed findings.
- /api/auth/logout was a no-op for a bearer credential: destroySession only
clears the cookie, leaving an Authorization: Bearer hlk_ token live until
expiry. When the request carries a bearer, also flip the matching
ApiToken.revoked and revoke its paired refresh-token sibling. Cookie path
unchanged.
- A browser spoofing X-Client-Type: native was handed a 60-day refresh token
in the JSON body. Gate refresh-token issuance on a new
isCookielessNativeCaller check (no Mozilla UA, no inbound session cookie),
so the secret reaches genuine cookie-less native callers only and never
alongside a browser session. The iOS native path is unaffected; a spoofing
browser falls back to the short-lived access token with no refresh.
- Refresh-reuse revoke trusted the stored row's deviceId, so a stolen token
replayed under a fabricated X-Device-Id confined the revoke to the
attacker's own id and left the victim's family live. On a present-but-
mismatched deviceId, escalate to the user-wide family revoke (a real device
never changes its id mid-family). The matching-id and null-id cases keep
their existing scope.
- Tighten the auth:refresh rate limit from 60/15min to 10/15min, aligning the
high-value rotation endpoint with the passkey-verify tier.
Add coverage for each: logout revokes bearer + sibling, a cookie/Mozilla
caller never receives a refresh token, a device-mismatch replay triggers the
wide revoke, and the cookie-less native path still gets its refresh token.
(cherry picked from commit 14dcdefc5f40855305d8aaf573d073faebc61476)
The .planning directory holds internal per-release working notes and the
iOS coordination channel, none of it user-facing. Ignore the whole tree so
the public repo carries only the curated surfaces (README, CHANGELOG, docs).
The Fitbit & Pixel tag chip paired muted text on a muted background (below
WCAG AA); use foreground text. Update the mobile integrations e2e to expect
the fourth integration card (Google Health).
Use FITBIT_ACTIVITY_PAGE_SIZE for the sleep and exercise reads instead of a
magic 25, remove the unused FitbitDataTypeKey type, and declare the crosstab
day floors independently so they aren't a duplicate re-export of the influence
floors.
Drop the no-op single-argument cn() wrapper on the chooser button
class (and its now-unused import), and replace the three-way nested
ternary that resolves the form-sheet title with an explicit
per-kind lookup map. The null-kind fallback keeps the prior
ternary's mood label, and the sheet stays closed in that state so
the title is never read.
(cherry picked from commit 856e620754753570e0d4ce8d6ce982778e929b75)
The family factor existed only as a binary tag; add a rated factor_family
(scale 1..5, not inverse) under the factors category so it can be scored
per entry. Additive seed, read dynamically by the tag catalog.
Refresh the README for the v1.12 line: add the experimental Google
Health/Fitbit connection (Fitbit/Pixel over the Google Health API, BYO
OAuth client, server-side activity/health-metric/sleep/workout sync) and
flesh out the existing WHOOP entry with its connect/test/sync/resume
controls. Rebuild the Mood section around the five-face capture, the
structured tag catalog (feelings/sleep/health/social/work/hobbies plus
nutrition tags), and the rated daily-life factors, and note the moodLog
webhook is deprecated. Add a Source priority section explaining why a
per-metric source ladder picks one canonical reading when providers
overlap. Bring the status line, integrations table, env-var table, API
reference, and roadmap current.
(cherry picked from commit 66d0987895e9a9d560afc456d91f38d54f7423ce)
Lift the bottom-bar center capture action into a proper floating-action
button: a 56px target raised out of the bar with a card collar ring and
a stronger shadow, so it reads as the primary CTA rather than a fifth
flush tab. Desktop sidebar nav is unaffected.
Unify the selectable controls across the mood logging surface onto one
design language: the 5 mood faces, the tag tiles, the rated-factor steps
and the GLP-1 chips now share a single selected-state treatment
(border-primary, bg-primary/15, primary text, single border) and a
consistent rounding family. Match the More-hub row height to the capture
tiles so equivalent tappable rows share one min-height at the same tier.
Give the demoted doctor-report export card a primary-tinted icon to match
its peer cards — the demotion comes from position and size, not a muted
icon. Drop the doubled heading atop the mood sheet by demoting the hero
question to a field label so the sheet title is the single primary
heading. Reword the capture mood option description to describe the
action instead of echoing the hero question.
(cherry picked from commit 1296f151f1c9a7c95ff917e3d69a5c0d369ba210)
Snapshot the incremental watermark once per sync cycle and thread the same
lower bound to every resource (metrics, activity, sleep, workout), stamping
lastSyncedAt once at the end. Previously each resource read lastSyncedAt then
stamped now() independently, so the first resource advanced the watermark and
later resources only fetched the last overlap window — silently dropping the
gap after an outage longer than the overlap. The full/backfill path is
unaffected (it passes no lower bound).
Reject an OAuth callback whose token response carries no refresh_token instead
of persisting an empty string. An empty refresh token bricks every future
token refresh; the callback now errors out cleanly so the user re-consents
rather than landing a dead connection.
Model the INTERVAL data types (steps/distance/calories/floors daily totals,
sleep and exercise sessions) on Google Health's interval.start_time /
interval.civil_start_time anchor instead of a sample_time / bare date. The
incremental filter for sleep/exercise previously targeted
sample_time.physical_time, which 400s/empties for interval types and stalls
incremental sync. Daily-summary metrics and spot readings keep their existing
date / sample anchors. Shape mismatches still fail safe (return []/null).
Mark the Google Health (Fitbit & Pixel) settings card as experimental with a
badge and a short note that data coverage is still being verified, in all six
locales.
(cherry picked from commit 3f116802149eebe9144729e2779f7f4405f248f2)
Thread the acting user id through createTagLinks, replaceTagLinks, and
replaceRatedFactorLinks, and assert the target MoodEntry belongs to that
user before writing any link. The check runs against the same client (tx
or singleton) as the write, so it shares one snapshot. Behaviour is
unchanged for the existing POST and bulk paths, where the entry is freshly
created and owned; the guard closes a latent cross-user link hazard for
the planned PATCH mood-edit route. A mismatched or missing entry throws
MoodEntryOwnershipError before any catalog or join touch.
Add IF NOT EXISTS to every ADD COLUMN in migration 0119 so a clean replay
is idempotent, matching the 0117 convention.
(cherry picked from commit 1bf983f6c2c5e44796c99983d57c1b1d90cd7099)
Extend the mood-relations surface from mood to a health metric. For each
structured mood tag present on enough days, compare a metric's value on
tag-present vs tag-absent days: active energy on workout days (same day),
sleep length on nights tagged sleep (same day), and next-day recovery
after a tag like alcohol or food (D to D+1 lag). The comparison reuses the
existing statistics engine wholesale — the Welch two-sample t-test, the
per-group day floors, the confidence band, and the Benjamini-Hochberg FDR
control already used by the influence and discovery boards — so no new
statistics code lands. Only pairs that clear p < 0.05 and the FDR control
surface, ranked by adjusted q then absolute delta and capped at eight rows.
The result rides on GET /api/mood/insights as a new additive
`tagMetricCrosstab` field and renders as a confidence-gated, observational
card on the mood insights page, framed as an association in your own data
rather than a cause. The endpoint is server-rendered and not in the OpenAPI
contract, so no contract change is required.
Deprecate the standalone moodLog integration. Mood is now tracked fully
inside HealthLog through entries, structured tags, and rated factors, so
the external webhook and reverse-sync bridge no longer add anything. The
webhook route, the push and pull helpers, and the validation schemas carry
an @deprecated note pointing at the native path; the surface stays
functional this release and is slated for removal in a future major. The
settings card shows a deprecation hint, localised across all six locales.
Place a monochrome ghost wrench icon-button immediately to the left of
the dashboard add button, linking to the layout/tile editor at
/settings/dashboard. The button carries an aria-label and matches the
add button's 44 px mobile touch-target floor, shrinking to the icon
footprint on sm+. New customizeDashboard string lands in all six
locales.
Reshape the mobile bottom bar to the five-slot shape the native client
uses: Home · Meds · Log · Insights · More. The center "Log" slot is a
capture action rather than a destination — it opens a picker that routes
to the existing measurement, medication-intake and mood quick-entry
sheets (those forms already share a mount contract, so the picker reuses
them verbatim). Expand the former overflow into a real "More" hub that
holds the rest of the top-level destinations: Measurements, Mood,
Workouts, Achievements, Notifications and Settings.
The change is additive — Measurements and Mood leave the always-visible
strip but stay reachable through the More hub and the capture picker, so
nothing loses its route. On desktop the sidebar still lists every
destination.
Move the metric/imperial unit system from its standalone card into a
dropdown in the Profile form beside the timezone picker — it is a
display preference like language and timezone. Persistence is unchanged:
the dropdown PATCHes /api/auth/me/unit-preference on change and
invalidates the same query keys, so chart display transforms re-render
without a reload. Retire the now-unused card.
Extract the Coach chat surface (header actions, message thread,
composer, provenance rails, settings, mobile trays) into a shared
<CoachConversation> component so the drawer and a new full-page route
render the exact same implementation — no forked chat logic. The drawer
keeps only its <Sheet> chrome (close button + maximize control); the
page supplies a minimize-back-to-drawer control.
The maximize control in the drawer header navigates to /insights/coach
and closes the drawer (aborting any in-flight stream); the page's
minimize control routes back to /insights and reopens the drawer through
the shared launch context. Both surfaces preserve incremental streaming,
collapsible history + provenance, the single composer disclaimer, the
auto-grow composer, refusal / budget handling, and the aiMode gating —
the full page redirects to /insights when the operator flag is off or
the user has opted out.
Lift useResettableValue / nextResettableValue into their own module so
the shared surface seeds its composer from the same controlled-prefill
contract without importing the drawer shell. Register the new route in
the coach feature-flag and per-user-disable gate-site invariants, and
add the maximize / minimize labels across all six locales.
A weekly injectable (e.g. a GLP-1 dose) with real recorded intakes
reported 0% compliance on the medication card across every window
(7 / 30 / 90 days). The web reads the server-computed rate verbatim,
so the server value itself was wrong.
The intake-to-slot matcher used a fixed +/-12h pairing radius. That is
correct for a daily cadence (24h inter-slot gap) but far too tight for a
once-weekly dose: a real intake is rarely logged within 12h of the
schedule's configured time-of-day, because the user takes the shot on
whichever day and time of the dosing week suits them. Those intakes fell
outside the radius, every weekly slot read "missed", and the rate
collapsed to 0% while the matching intakes were orphaned.
Scale the pairing radius with the cadence gap. `pairDoses` now derives a
per-slot radius from half the distance to its nearer neighbouring slot
(so two adjacent slots never double-claim one intake), floored at the
12h base so daily and multi-dose-daily cadences keep their exact prior
behaviour. `buildCadenceTimeline` additionally probes the schedule's
intrinsic cadence over a padded window and passes half the minimum
inter-slot gap as a radius floor, so a window holding a single expected
slot (a weekly med over a 7-day window) widens correctly too.
A weekly cadence now pairs an intake logged anywhere in the dosing week
to that week's slot, reporting the true rate. Daily-cadence matching is
unchanged.
Adds unit coverage for the widened radius and a real-Postgres integration
test mirroring the weekly-injectable row + off-time intakes.
Replace the score-number radio + free-text form with an icon-first
"How are you?" hero that matches the native client: a row of five mood
faces in best-on-the-left order is the primary input, and picking one
reveals a brief annotate panel (time, the category-grouped tag picker,
factor ratings, and the note field).
The tag picker now reads the catalog's `kind` / `scaleMin` / `scaleMax`
metadata. BINARY tags stay icon-above-label toggle tiles sent as
`tagKeys`; RATED factors (work / social / sleep quality / stress, and
the 1..2 Yes/No conflict factor) render a compact 1..scaleMax segmented
control and are sent as a parallel `ratedFactors: [{ key, rating }]`
array. The entries list / history stays reachable below the hero.
Add the mood-face icon map (Laugh / Smile / Meh / Frown / Angry) and the
`heroQuestion`, `factorYes`, `factorNo`, `factorRatingOption` strings
across all six locales.
The per-metric pages are the canonical home for each metric's primary
tile, target band, and correlation card. The overview was still
rendering its own correlation row (weight × weekday, mood × pulse,
BP × compliance) — the exact cross-view duplication to kill now that
the Weight and Pulse pages own those cards via MetricCorrelationCard.
Remove the on-overview CorrelationRow (component + test) and reorder the
overview toward the iOS Insights structure: hero (coach + briefing +
prompt chips) → detailed daily briefing → dynamics/alerts (today's
signal + rhythm events) → vitals dashboard → trends → period
retrospective → warm-assessments footer control. The overview carried
no target-tile grid, so nothing to remove there.
Drop the now-dead insights.correlationRow.subtitle key across all six
locales; title and disclaimer stay (the per-metric correlation card
still uses them).