mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
6871e8ea77a01fbfbce75f0f61b29bb70012b9e0
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a478d3028 |
feat(whoop): native-scheme connect redirect and Bearer connect ticket
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) |
||
|
|
540abebf09 |
feat(ops): alarm on a TLS leaf SPKI change for pinned native clients
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) |
||
|
|
3021d54068 |
fix(fitbit): skip tombstones on sync, batch backfill writes, bound poll concurrency
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) |
||
|
|
686bc1bf30 |
feat(fitbit): sync Google Health metrics with backfill and poll jobs
Extend the Fitbit/Google Health provider with the read path: the dataPoints.list client half, the metrics-bundle sync, the poll + backfill jobs, and the connection-management routes. Builds on the existing OAuth + credentials + token-refresh layer. - client: paginated `fetchDataPoints` walker over `safeFetch` keyed on `nextPageToken`, encoding each data type kebab-case in the path and snake_case in the filter predicate; defensive per-type mappers (finite + positive guards) for weight, body fat, daily SpO2, daily HRV, daily resting heart rate, daily respiratory rate, intraday heart rate, sleep-temperature derivations, and a height profile-seed extractor. `FITBIT_FIELD_MAP` + `mapping.md` are the source of truth. - mapping (no new MeasurementType): weight→WEIGHT, body-fat→BODY_FAT, SpO2→OXYGEN_SATURATION, HRV→HEART_RATE_VARIABILITY (SDNN slot, not HRV_RMSSD), resting-HR→RESTING_HEART_RATE, respiratory-rate→RESPIRATORY_RATE, heart-rate→PULSE, sleep-temp→WRIST_TEMPERATURE, height→User.heightCm when null. Steps/distance/energy/floors/VO2 + sleep + workouts stay for a later wave. - sync: `syncUserFitbit` orchestrator with the reauth short-circuit, the per-data-class 403 soft-skip (the six Restricted bundles grant independently), the all-soft-skipped success guard, and incremental-vs-full windows; `upsertFitbitMeasurements` keyed (userId,type,FITBIT,externalId) with a 24 h overlap for after-the-fact daily re-rolls, recomputing rollups and invalidating status caches. Google refresh tokens do not rotate, so the stored refresh token is kept unless a new one is returned. - jobs: hourly `fitbit-sync` poll (poll-only; Pub/Sub deferred), the self-converging boot backfill, and the daily OAuth-state sweep — every queue registered in both `allQueues` and a `boss.work` binding, staggered off the WHOOP/Withings crons. - routes: status / sync / disconnect mirroring the WHOOP shape. Tests cover the mappers, the casing invariant, `fetchDataPoints`, the orchestrator soft-skip guard, the idempotent upsert, the backfill discovery, and the queue registration; an integration test pins the upsert idempotency, the DAY rollup fold, and the Fitbit-vs-Withings per-source no-collapse against the Postgres testcontainer. |
||
|
|
7ab2cbaa56 |
feat(fitbit): add Google OAuth, credentials, and token refresh
Land the OAuth + credentials half of the Fitbit / Google Health provider, cloning the WHOOP provider structure and adapting it to the Google identity flow. - Client OAuth half (src/lib/fitbit/client.ts): Google authorize-URL builder (access_type=offline + prompt=consent), authorization-code exchange, token refresh, and a profile fetch for the connection's external user id. Client credentials ride in an HTTP Basic-auth header on the token endpoint; the four launch Restricted read scopes are pinned in FITBIT_OAUTH_SCOPE. All egress goes through safeFetch. - Non-rotating refresh tokens: getValidToken refreshes at expiry-minus-five- minutes, persists the new access token + expiry, and overwrites the stored refresh token ONLY when the response carries a fresh one — Google keeps the refresh token stable, unlike WHOOP. - BYO credentials route (PUT/GET/DELETE /api/fitbit/credentials): per-user client id/secret stored encrypted on the User columns, built field-by-field under requireAuth. - OAuth state + connect + callback routes: a random base64url nonce backed by a 10-minute FitbitOAuthState ledger row carries the (nonce -> userId) mapping; the callback validates the cookie/URL state with timingSafeEqual, consumes the row atomically (replay/expired/cross-user reason tags), exchanges the code, upserts the encrypted FitbitConnection, clears prior reauth state, and enqueues the self-converging history backfill. - Response classifier mirrors WHOOP's HTTP-status buckets; Google signals a revoked grant with a 401, so there is no invalid_grant body special-case. Tests cover the authorize URL, Basic-auth token exchange, the no-rotation refresh branch (token preserved when omitted, overwritten when present), the classifier status table, and the OAuth-state nonce mint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7e29595a71 |
feat(coach): wire the combined memory-refresh worker queue
Add the coach-memory-refresh pg-boss queue: a fire-and-forget enqueue contract (coach-memory-shared) and a worker pipeline that runs the conversation-summary refresh then the fact extraction for one long conversation, each fault-isolated and budget-gated. Register the queue in allQueues + a boss.work handler in the worker, with a source-grep inventory guard so the queue can never ship unregistered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5917bba61c |
feat(insights): period-narrative summary on the overview
Add a calm "your week / month in review" summary to the Insights overview, generated from the period-narrative context (deltas, personal bands, FDR-controlled associations, coincident days). - New insight_narratives table (migration 0113): one durable row per (user, period, locale); generated prose AES-256-GCM at rest following the coach-message precedent; labels-only provenance stays plaintext. - generatePeriodNarrative feeds the context into the provider chain with a tight, descriptive-never-causal prompt; an insufficient context yields no narrative, a missing provider no-ops, neither fabricates. - Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated per user; the read-only GET enqueues a single-user warm on a cold read. - Read route is stale-while-revalidate and never blocks on the provider; the card renders plain text (no markdown), a single provenance disclosure, and a fixed footprint (no CLS). i18n in all six locales. |
||
|
|
337f024a87 |
feat(whoop): pg-boss sync workers + self-converging backfill
Build the WHOOP sync layer on the W2 client, mirroring the Withings queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's after-the-fact re-scoring, maps via the W2 mappers, and upserts Measurements with source=WHOOP keyed on (userId,type,source,externalId) so a re-post overwrites in place. Workouts upsert into Workout with per-workout strain + HR zones in metadata and kJ->kcal energy. Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior access and refresh token on every refresh). A self-converging boot backfill enqueues one full-history sync per un-backfilled connection and stamps backfillCompletedAt; the discovery query drops completed accounts, idempotent across reboots. Every new queue is registered in the worker's allQueues + cron schedule (recovery/sleep/workout poll catch-all, cycle poll-only, backfill, oauth-state sweep). IntegrationStatus gains the whoop key + label. Tests: rotating-refresh persistence, idempotent measurement upsert, incremental-window overlap, backfill self-convergence, and a queue-registration inventory guard. |
||
|
|
efa132b1aa |
fix(insights): make the score candidate finders deterministic
The recovery / stress / strain nightly finders used distinct + take with no order, so when more than the cap of users qualified the selected set was arbitrary across runs. Switch to a grouped query ordered by each user's newest input first with userId as the stable tiebreak, so the cap takes the most-recently-active users reproducibly. The strain finder interleaves its workout and active-energy lists so the merged cap favours recency across both sources. |
||
|
|
8456d3bbc3 |
chore: correct body-cap comments, dockerignore licence posture, stale log copy
- workouts/batch + safeJson: the body cap is a DoS ceiling, not a bound the worst-case payload always fits under; an extreme GPS-heavy backlog can exceed it and falls back to smaller batches client-side. Note that request.text() still buffers the raw body, so the cap bounds the parse cost, not the initial read. - .dockerignore: keep LICENSE / LICENSE.md in the image — AGPL-3.0 conveys with the software. - reminder-worker: the dense walk flag is an operator kill-switch now that the fold is reworked, not a pending-rework gate. |
||
|
|
64965162c7 |
feat(measurements): re-enable the dense intra-day retention drain
The v1.10.0.2 kill-switch defaulted the drain OFF after the P2002 fold collision. With the fold reworked to coexist with the canonical daily row and integration-tested against real rows, flip the default back ON (DENSE_INTRADAY_RETENTION_ENABLED=true unless explicitly set to false). The boot-discovery still defers past the startup storm via the boot-delay so it never contends for the connection pool on a data-heavy tenant; the nightly 03:50 walk and the per-user handler run again. |
||
|
|
7bd66615b6 | fix(jobs): disable the dense intra-day retention drain (P2002 fold collision) | ||
|
|
3080828fb7 | fix(jobs): defer the dense intra-day retention boot drain past startup to avoid pool exhaustion | ||
|
|
148e6cd600 |
fix(insights): score the previous day in the nightly score engines
The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55 Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours old: Strain missed the just-completed day's workouts and Stress saw a near-empty intra-day SDNN set and falsely gated as insufficient. Score the day that just ended (now - 1 day) across all three engines and their measuredAt / externalId day stamps. Resolve the previous-day key in one place by extracting the shared score-row helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt / scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into the baseline engine (the three byte-identical profile loaders + the partial strain variant now route through it). The derived route should adopt loadBaselineProfile too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c5ba38cebb |
feat(insights): register the stress, strain, and dense-retention jobs
Wire the two nightly score passes and the dense intra-day retention drain into the worker. The stress (04:50) and strain (04:55) crons each get a queue, a schedule, and a single-flight handler; the dense-retention drain gets a per-user backfill queue with boot discovery (like mean-consolidation) and folds its steady-state nightly walk onto the existing drain-cumulative tick, after the daily-mean fold. A queue not registered in allQueues silently never drains, so a source-grep guard pins every registration site for all three jobs. |
||
|
|
6935feda15 |
feat(insights): compute and store a nightly strain score from workout load
Derive a daily cardio-load proxy from the per-workout heart-rate series plus active energy. Each workout's HR series scores via Banister's gender-weighted exponential TRIMP over the fractional heart-rate reserve (Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals map to a 0-100 saturating scale. Days with no usable HR series but a non-trivial active-energy total fall back to an energy-only proxy, with the path recorded in the result. Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated when neither a usable HR series nor active energy is present. Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP); Tanaka, Monahan & Seals 2001 (HRmax). |
||
|
|
57d9cc1674 |
feat(insights): compute and store a nightly stress score from intra-day HRV
Apple Watch has no electrodermal sensor, so there is no direct stress measurement to read — what HealthKit does give is discrete SDNN samples through the day. Derive an honest HRV-through-day PROXY: a suppressed, below-baseline intra-day SDNN is a recognised marker of higher sympathetic load. The provenance and label say "HRV-derived proxy" and never imply a galvanic/EDA reading. The engine reuses the shared vitals-baseline math (the same SDNN baseline the wellness index reads), maps the day's below-baseline suppression to a 0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a client can never POST it. It gates and writes nothing below the minimum intra-day sample floor or without a usable baseline. Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012 (neurovisceral integration). Idempotent per user per day via the nightly job. |
||
|
|
ce2b745cc3 |
feat(measurements): add a bounded dense intra-day retention tier for HRV/HR
Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep their intra-day shape long enough for the stress engine to read them, but the raw per-sample volume must stay bounded. Add a retention tier that keeps the last 14 days of raw HRV/HR samples and folds everything older to one daily-mean row via the proven mean-consolidation drain (same idempotent stats:<HK>:<day> fold, soft-delete, and rollup recompute). The two dense-tier types are deliberately EXEMPT from the destructive daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would collapse the intra-day shape and soft-delete the raw rows. A regression test pins both exclusions so a future edit that adds either type to the drain allowlist fails loudly. Migration 0108 adds only the partial index that keeps the retention drain's discovery and per-user scan cheap; the retention bound lives in code so tuning it never needs a migration. |
||
|
|
3dcec54726 |
feat(insights): compute and store a nightly recovery score
Add a recovery-score engine that turns a user's already-stored nightly signals into a stored 0-100 score. It reuses the readiness blend — the resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation from the personal baseline, with the null-redistribution the health score already uses — so the stored series and the live readiness surface can never drift. The lineage is the recovery-marker literature (Plews 2013, Buchheit 2014): a higher score reads as more recovered. It is a descriptive wellness proxy, not a clinical or training-recovery assessment, and it is only stored when enough components are present — below that floor no row is written rather than a headline from one signal. A nightly job computes each eligible user's score and upserts it as a COMPUTED RECOVERY_SCORE row, idempotent per user per day on the externalId day key, so a re-fired tick overwrites in place instead of duplicating. The score is never computed on a page visit. The queue is registered in the worker's queue inventory, scheduled, and wired to a handler; a grep guard pins all four points so it cannot silently never run. |
||
|
|
b13721cc97 |
fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s timeout and then warms the per-status + generic-metric caches itself. The timeout could not cancel the underlying generation, so a comprehensive that resolved after the timeout reached its own per-status eviction and deleted the rows the warm passes had just written — leaving the user back on a cold cache. Thread an AbortSignal into the comprehensive generation and abort it when the timeout fires; the generation now bails out before its write + evict once the signal is aborted, so a late resolve can never undo the warm. Also align the force-warm gating with the route: the route admits the job on the per-user assessment surface, so warm the per-status + generic-metric cards whenever that surface is enabled, even when the global briefing switch is off. Only the comprehensive briefing stays gated on the briefing switch. Factor the per-status cache-action shape into a single helper so the key is built in one place rather than hand-assembled across the generators, the eviction sweep, and the ingest debounce. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15) |
||
|
|
2c6a9f8165 |
fix(insights): decouple the forced warm sections so a slow comprehensive step cannot starve the rest
The on-demand force-warm ran the comprehensive insight first and only ran the per-status (7) and generic-metric (~30) assessment passes when that step returned generated or cached. A slow or stalled comprehensive generation therefore aborted the entire warm — observed in production as a 102 s run ending with assessments_warmed: 0 — leaving every card cold and the user back on the lazy 30-60 s on-first-click path. Give the comprehensive step its own bounded budget and treat a timeout, error, or skip as non-fatal: the per-status and generic-metric passes now always run afterwards. Each per-card generator resolves its own provider chain and bounds its own provider call, so running them after a failed comprehensive costs a cheap chain-resolve when no provider is configured and is capped per card otherwise. The result reports each section's outcome independently, and a timed-out comprehensive is reported distinctly from a failed one so a partial warm is observable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit f52f8e9c3b32590f3b085d082418ebdd3a923f1d) |
||
|
|
bf5052bb01 |
feat(insights): add on-demand full assessment warm
Add POST /api/insights/pregenerate so every AI assessment text — the
comprehensive insight, the seven specialised status cards, and every
data-bearing generic metric assessment — can be warmed for the calling
user on demand, in the active locale, instead of waiting for the nightly
cron or a lazy first-visit generation.
The route narrows userId from the session/Bearer, runs the assistant
surface gate, and enqueues a forced full-warm job onto the existing
insight-pregenerate queue, returning immediately. A short per-user bucket
(insights-warm:<userId>, one per three minutes) returns 429 on a tight
loop so the bypassed nightly 20h budget cannot be turned into a provider
cost amplifier.
The worker branches on the payload: a job carrying { userId, force }
runs forceWarmUser for that user, skipping the candidate scan and the
20h budget; a scheduled tick with no payload keeps its existing batch
behaviour. forceWarmUser reuses the existing comprehensive generator and
warm helpers — no new generation logic — and stays safe by construction:
the comprehensive generator no-ops without a provider, the generic warm
pass filters to metrics the user has rows for so an empty metric never
reaches the provider, and every write is idempotent.
The queue name and a generator-free enqueue helper move to
insight-pregenerate-shared.ts, mirroring the insight-status-generate
split, so the route enqueues without pulling the generator tree.
Regenerate the OpenAPI contract for the new route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e10ae9bfeca544c7e5aecd9102459d97ddfad6db)
|
||
|
|
e33911e2d4 |
feat(insights): generic per-metric assessment coverage for HealthKit metrics
Extend the assessment-card system beyond the seven specialised scopes to the ~30 HealthKit metric pages, without hand-writing a prompt per metric. - Add a metric-metadata registry keyed by HealthKit id (display name, unit, direction, optional normal range, archetype, scope), covering the data-bearing HealthKit metrics. The seven specialised metrics keep their own scopes and are excluded from the generic set; an unregistered id gets no card. - Add a generic data-driven generator that reuses the existing graded snapshot, provider runner, stale-while-revalidate cache, and persist path. One prompt template per archetype (physiological-vital, activity-fitness, body-composition, mobility-gait, environmental-exposure) plus a dedicated sleep template, parameterised by metric metadata. Empty-data metrics return an insufficient marker with no provider call. Normal ranges use age + sex where the profile stores them; no ancestry/region ranges. - Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped, Zod-validated against the closed registry id set (422 on unknown), read-only with stale-while-revalidate. First insights route with OpenAPI coverage; regenerate docs/api/openapi.yaml. - Route the generic metric:<id> scope through the on-demand status queue worker dispatch. - Extend the nightly warm pass to the generic metric caches for a user's data-bearing metrics only (one distinct read gates the set), keeping de+en warm and never calling a provider for an empty metric. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450) |
||
|
|
93790aa043 |
feat(measurements): consolidate gait/mobility HealthKit metrics nightly
Extend the daily-mean consolidation to cover the high-frequency gait/mobility metrics that previously mapped to no consolidation set and piled up at sampling granularity: walking asymmetry, walking double-support, walking steadiness, and walking heart-rate average. The correct daily reduction for these is the mean, matching the Health-app display; all four stay disjoint from the cumulative (sum) set. Fold the daily-mean drain onto the existing nightly cumulative-drain tick so new high-frequency spot samples no longer accumulate raw between worker reboots. The two passes share one concurrency-1 cron, keeping the maintenance window a single sequential walk. Boot-time discovery stays as the back-fill for accounts that accumulated raw rows before this shipped. Recompute the affected day's rollup bucket after the consolidation commits, the same way the ingest path does, so a rollup-covered read reflects the single consolidated row rather than a stale mean derived from the now soft-deleted per-sample rows. Regular heart-rate / pulse stays raw: it is a discrete clinical reading and feeds correlation and scatter analytics. No schema change. |
||
|
|
35800becbb |
fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric status card fired a cold, uncapped LLM fetch on mount with no client abort, so the request hung up to the server's 60s ceiling and the pending/resolve re-renders thrashed the tab strip and ate touch events. Timeouts were never negatively cached, so every visit re-blocked. Fix the class holistically: - Bound every status GET on the client. The shared use-insight-status hook and the inline medication-compliance query now wrap fetch in an 8s AbortController and stop retrying inline; navigation can no longer await an uncapped round-trip. - Make the status routes read-only. On a cache miss the generator enqueues an out-of-band pg-boss job (insight-status-generate) and returns a preparing envelope; the client polls a bounded interval until the worker warms the cache. Models the dashboard snapshot's ready/preparing pattern. The queue is registered in the worker allQueues loop + a boss.work handler, with a guard test. - Fix the nightly warm-pass regression: warm every supported locale (the client reads against the active UI locale, not the persisted User.locale) and warm on a cached comprehensive outcome too (its write still evicts the per-status caches). Add a short-TTL negative cache on the timeout path so a transient provider stall no longer makes every subsequent visit re-block or re-enqueue. - Add loading.tsx for the insights segment + the root so any future RSC await streams a skeleton instead of blocking the transition. - Stabilise the advisor regenerate callback so a status-query flip can't re-render the memoised tab strip mid-gesture. New i18n key insights.assessmentPreparing across all six locales. |
||
|
|
5047eb7a20 |
fix(medications): collapse pre-existing duplicate dose-slot rows at boot
The write-path convergence stops new duplicate slot rows, but accounts that already accrued a pending + taken pair for one dose keep an inflated expected count, a phantom taken marker, and a suppressed "due now" prompt until the bad rows are cleared. Add a boot-time, idempotent cleanup queue (mirroring the existing backfill queues): discover only the users holding a same-slot duplicate, then per medication snap every live intake to its canonical slot, keep the most-actioned row per slot (taken over skipped over pending), normalise its instant, soft-delete the losers, and recompute the affected compliance rollups. Rows that resolve to no scheduled slot (PRN / as-needed) are never collapsed. A second pass is a no-op. Registered in the worker's queue list with a static guard test, after a prior incident where a queue was built but never registered and silently never ran. |
||
|
|
0d7d08f616 |
fix(medications): converge intake writes onto one row per scheduled slot
A twice-daily medication ended up with two rows for one dose slot: the projector / reminder worker mints a pending row (source REMINDER) at the slot's `localHmAsUtc` instant, while a "taken" write from the per-med or bulk intake route inserted a fresh row (source WEB / API). Because `source` is part of the `(userId, medicationId, scheduledFor, source)` unique key — and the written instant could drift by a minute from the projected one — the two rows coexisted: one taken, one still pending and overdue. The card then read the slot as taken, counted 100%, and suppressed the "due now" prompt for a dose the user had not taken. Resolve every scheduled intake write to the slot's canonical instant before writing and upsert in place, source-agnostic: - `resolve-slot-instant.ts` snaps an incoming instant to the nearest scheduled slot for that day (enumerated through the canonical recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and anchors it on the same `localHmAsUtc` the projector and worker use. Off-slot and as-needed writes resolve to null and keep insert semantics. - The per-med and bulk routes look up the existing row at the canonical `(userId, medicationId, scheduledFor)` ignoring source and update it in place — mirroring the status-toggle route — instead of inserting a sibling. The bulk per-entry response gains an additive `updated` status. - The reminder worker's missed-dose mint now skips a slot that already carries an actioned row (taken or skipped) from any source, not only an existing pending REMINDER row. PRN / as-needed writes are unaffected. Regression tests pin the collapse (exact and one-minute drift), the PRN exemption, and the worker guard. |
||
|
|
67a72b1023 |
perf(insights): parallelise the nightly per-user assessment warm pass
The warm pass forced the seven per-metric status generators strictly back to back per user, so a fully configured account paid ~7× one LLM round-trip serially. Bound the inner pass with a small `p-limit(3)` so the cards warm a few at a time without saturating the Prisma pool or fanning out an unbounded number of provider calls. The per-user budget gate still bounds total cost per night. |
||
|
|
177ce576d3 |
feat(insights): warm the per-metric assessment caches in the nightly pre-gen
The comprehensive generator evicts the seven `insights.<scope>-status` caches as part of its write so a stale comprehensive insight can never leave a stale per-metric card behind — but it does not re-fill them. Before this, the morning after the nightly pre-gen cron ran the per-metric status caches were empty, so the first `/insights` mount still ran one live status generation per card. After a successful comprehensive generation the cron now forces the seven `*StatusForUser` generators so their caches are warm, not empty, by the user's first visit. The warm pass only runs on a `generated` outcome (a no-provider `skipped` has nothing to warm; a `failed` run means the chain is unhealthy this tick — re-running it seven more times would only multiply the failure). Each generator resolves the provider chain internally, so a provider-less account stays cheap. The per-user budget bucket that already bounds the comprehensive pass also gates the warm pass — both run behind the same once-per-20h gate. `runInsightPregenerate` now reports `assessmentsWarmed` and emits an `insights.pregenerate.run` wide-event with the full per-run tally so the nightly dashboard can track how many assessments each tick wrote. Also adds `invalidateStatusInsightsForTypes` + `PER_STATUS_SCOPES` to the comprehensive generator: a type → scope map (WEIGHT → weight + bmi + general, BP → blood-pressure + general, PULSE/resting-HR → pulse + general, everything else → general) so the ingest path can drop only the scopes a fresh reading dirties. |
||
|
|
50e0a65228 |
chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8528c3b4fe |
chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
344d1c33b0 |
feat(meds): route the reminder worker through the canonical recurrence engine
Two changes that ship as one because they share a single rewrite of the worker's schedule-resolution site: ### The bi-weekly fix `grep intervalWeeks src/lib/jobs/reminder-worker.ts` used to return zero hits. The worker iterated `recurrence.daysOfWeek.includes( todayDow)` and called it good — a bi-weekly Wednesday schedule fired EVERY Wednesday, regardless of "on" vs "off" week. The cadence chart respected the interval; the worker did not. This is the single line at `reminder-worker.ts:514` and it's been wrong since `intervalWeeks` was introduced. The fix routes the same predicate through the new canonical engine (`src/lib/medications/scheduling/recurrence.ts`). The engine honours INTERVAL natively for RRULE schedules and honours the legacy `intervalWeeks > 1` arithmetic on the fallback path. Both paths now agree with the chart. ### New cadence types The same rewrite picks up monthly / quarterly / yearly / rolling / one-shot for free because the engine dispatches on the schedule's shape. The worker doesn't special-case anything — it just asks the engine. ### Rolling needs a `lastIntakeAt` Rolling schedules anchor next-due on the latest `MedicationIntakeEvent.takenAt`. The worker now does a conditional findFirst on the medication's intake stream when the schedule list contains a rolling row. The lookup is gated on `med.schedules.some(s => s.rollingIntervalDays !== null)` so the calendar-cadence common case keeps its single-query hot path. ### The seam — `worker-helpers.ts` A small module exports `buildCanonicalSchedule`, `buildRecurrenceContext`, `scheduleEmitsInWindow`. Keeps the worker file focused on phase math and dispatch (it's 2347 LOC; a deeper rewrite is for another release) and gives the integration tests a clean place to exercise the schedule-resolution path with real Postgres rows. ### Dead code retired - `todayDow` (line 492) — replaced by the engine's per-instant check. - `getDayOfWeekInTz` alias (line 355) — last caller was `todayDow`. - `parseScheduleRecurrence` import — the worker no longer touches the legacy decoder directly; the engine handles the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f92e644154 |
chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207. |
||
|
|
66a7f8b6fa |
chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170. |
||
|
|
ac319c6160 |
chore(release): v1.4.46 — server reconcile (PR worker + auto-skip + APNS admin test)
Three discovery follow-ups from prior server reviews:
- REG-9: pr-detection-worker now aggregates cumulative HK kinds (steps, active energy, flights climbed, walking-running distance, time in daylight) by date_trunc('day', measured_at) SUM before picking the best day, instead of picking the largest single per-hour slice.
- New intake-auto-skip cron at :05 hourly flips MedicationIntakeEvent.skipped=true for events where skipped=false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours', so the compliance tile stops seeing forgotten doses as pending forever.
- /api/admin/notifications/test now handles the APNS channel (was falling through to "Unknown channel type"); surfaces the APNs sender's reason on failure.
5076 -> 5089 tests (+13). 4 pre-existing date-rollover flakes in dashboard/summary, medications/intake, and medication-card-symmetry tests are unchanged by this PR (verified against pre-fix baseline
|
||
|
|
9b6e237f5f |
chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release: - W1-KNIP — exports/types tiers flipped to enforcing-mode on main; 35 unused exports + 52 unused types triaged to 0/0; shadcn surface + zod-infer contract types scoped via knip.json ignore-block. - W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper + `/api/dashboard/widgets` 422 now returns every Zod issue + worker audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43. - W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files migrated to the queryKeys factory; 9 new factory entries; ESLint rule + test-guard scope extended in lockstep. - W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine sites (seven status helpers + bucket-series + bp-in-target); Suspense double-comment consolidated; doctor-report sanitiser regex rewritten to escape-form so diffs are reviewable; pr-detection- worker soft-delete filter; offhost-backup DR-intent comment. - W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard tile-strip placeholder parity with the live TrendCard chrome. - W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier (transient/reauth/persistent) + WithingsApiError typed throw + the `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2 silent-disable gap the wave was conceived to close. No migration, no env-var change, no API contract break for iOS v0.5.4. The new `/api/dashboard/widgets` 422 envelope is additively extended. pnpm test --run green at 4815 passing / 1 skipped. pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green. |
||
|
|
1114cf7e63 |
chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA reconcile round before tagging. See CHANGELOG.md for the user-facing prose; this commit message documents the wave + report inventory for release-management traceability. Waves shipped (each with a phase report under .planning/): - phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the v1.4.39 empirical-trace finding #1 (Prisma pool starvation). - phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3 integration contracts close audit Critical Finding #3 (soft-delete invisibility) across dashboard, analytics, summaries, rollup tiers, and the Coach snapshot path. - phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three /api/insights/{features,targets,cards} routes plus the compliance-rollup hook gap closure for bulk-projection mints; the post-QA reconcile patch closes the six remaining measurement.findMany sites in the cluster that still leaked tombstoned rows. - phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation under src/lib/rollups/* with zero orphan re-exports; lint, typecheck, knip, and 93/93 rollup tests green post-restructure. - phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the queryKey factory-bypass walker test (extended to comparison/ in the reconcile pass). - phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17 paired-section tests pinning the 9 SB-3 disclosure requirements. - phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity assertions guarding against split rotation. - phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER time-sensitive APNs payload (priority 10 + interruption-level) scoped via a parameterised test that pins all 6 other event-types do not bypass Focus. AP-2 .p8 install gates real delivery. - phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + receipt: null. - phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers (week/month/year) wired through summaries-slice and health-score-fast-path with a linear-composability parity test pinning the cross-consumer routing contract. - phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation eliminates a sibling-drift risk; knip whitelist trimmed to three files pending follow-up. QA reconcile patches applied per the v1.4.40 QA round (six reports under .planning/round-v1440-QA-*-findings.md): - senior C1 — dashboard-suspense-boundaries regex aligned with the post-W-INFRA Thread 2 userTimezone-local lift. - senior H1 — soft-delete filter added to the six remaining insights measurement.findMany sites. - specialist H1 — Promise.allSettled around bulk-projection compliance recompute on both /api/medications/intake and /api/dashboard/summary. - ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets factory; walker test extended to src/components/comparison/. - security M1 — consent artefact 64 KB cap enforced via Buffer.byteLength (UTF-8 bytes, not UTF-16 code units). Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip --include files,dependencies,binaries,unlisted, pnpm test --run (4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over the v1.4.39 -> v1.4.40 span. User-facing prose: see CHANGELOG.md. |
||
|
|
d84922cdc3 |
chore(release): v1.4.39 — mood, medication-compliance, and cumulative-sum rollup tiers
Squashed merge of the v1.4.39 wave work from develop. The release extends the v1.4.38 fast-path posture to two more endpoints that still walked the source table on every cold mount and folds a cumulative `sum_value` column into the existing rollup tier so step / flight / distance / daylight / active-energy sparklines no longer re-derive their daily totals in Node. Raw measurements / mood_entries / medication_intake_events tables are byte-unchanged. Three new tiers (mood_entry_rollups, medication_compliance_rollups, measurement_rollups.sum_value) are derived caches that self-heal via boot-time backfill on first reach. Migrations 0070, 0071, 0072 are additive only. The sum_value column add is catalog-only DDL on PostgreSQL 11+ — no table rewrite, writes resume in milliseconds. No environment-variable change. No API contract break. No iOS contract change. Expected wins (live perf-verify rides the post-deploy window): - /api/mood/analytics cold 12.7 s -> ~200 ms - /api/medications/intake?scope=compliance cold 3.2 s -> ~200 ms - /api/dashboard/summary cumulative sparkline ~500 ms -> ~300 ms - /api/analytics live-fallback row cap 347 k -> ~5 k (425 d cap preserves the year-ago baseline tile) Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean. See CHANGELOG.md for the full v1.4.39 entry. |
||
|
|
9cc5ececa7 |
chore(release): v1.4.38.7
Rollup recompute observability + admin trigger. - POST /api/admin/rollups/recompute synchronously folds one user or re-runs the boot-discovery loop on demand. Admin-gated. - slim_summaries live-fallback now annotates `fallback_reason` and `missing_types[]` so the operator can map a slow cold-mount to the type missing DAY-bucket coverage without DB shell access. - Boot-backfill discovery now logs every result, including the silent "no users to backfill" case — the previous shape made it impossible to tell "discovery ran cleanly + found nothing" apart from "discovery never executed". See CHANGELOG.md. |
||
|
|
d224811a34 |
chore(release): v1.4.38.2
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch surfaced enough blockers that the feature could not have been used safely as shipped: - Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English. - Dedup ledger committed before delivery — a transient APNs blip silently nuked the day's nudge and blocked retries. - DST-unsafe arithmetic drifted the local-22:00 baseline by an hour twice a year for both the mood-reminder push and the medication iOS-snooze action. - APNs payload forwarded the Telegram `replyMarkup` inline-keyboard object and other channel-specific extras to Apple's servers and the iOS notification handler. - No Settings UI for the per-user opt-in flag — the feature was unreachable for end users regardless of the other fixes. v1.4.38.2 closes every item above plus adds the missing `MoodReminderCard` Switch, a 90-day retention sweep for the dispatch ledger, and a per-user `try` wrapper so one bad row cannot abort the 22:00 tick. See CHANGELOG.md. |
||
|
|
4049c6c724 |
feat(notifications): APNs category + MOOD_REMINDER event for iOS v0.5.4 (#190)
* feat(notifications): set APNs category for med-reminders
Unblocks iOS HealthLog v0.5.3 which registers a UNNotificationCategory
named MEDICATION_REMINDER with three actions (Take / Snooze 15 min /
Skip). Without aps.category on the push payload iOS renders the
notification as a plain alert, so the action-button row never
materialises on the lock-screen.
- ApnsPayload gains optional `category` + `mutableContent` fields.
The sender writes `category` through to node-apn's setter (a
forward-compat cast wraps the d.ts gap in node-apn 8.1).
- sendViaApns automatically forwards `payload.eventType` as the
category so every event-type that lands here surfaces as a
matching iOS category identifier without further plumbing. Future
event-types that the iOS app doesn't register fall back to a plain
alert — iOS silently ignores unknown categories.
- mutableContent=1 is set by default so an iOS Notification Service
Extension can hook the payload later without a server-side change.
- New unit tests pin aps.category=MEDICATION_REMINDER on the dispatcher
path, aps.category=MOOD_REMINDER for the new event-type, and the
explicit-category override on the lower-level sendApnsPush entry.
* feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger
Schema foundation for the v0.5.4 daily mood-reminder push:
- `users.mood_reminder_enabled` (Boolean, default false): per-user
opt-in flag. Default off because mood capture is an emotionally
loaded surface — a fresh-install user must explicitly toggle the
preference in Settings before the server starts nudging them.
- `mood_reminder_dispatches` (new table, unique on userId+date):
idempotency ledger that records exactly one nudge per user per
local date. Doubles as the race-guard for the every-15-min cron
re-ticking inside the same 22:00 window — the unique constraint
makes parallel workers safe even though the worker boots at
localConcurrency=1.
Migration is additive: existing users stay on the opt-out path
byte-identical to v1.4.38 behaviour. Uses IF NOT EXISTS guards
matching the idempotent pattern from 0061 / 0068.
* feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron
Second half of the v0.5.4 ios-coord patch. Now that the schema can
remember "user opted in" and "we already nudged today", wire the
worker side:
- types.ts: extend EVENT_TYPES with MOOD_REMINDER. Default off on
the per-event policy as a defence-in-depth gate behind the
per-user moodReminderEnabled flag.
- mood-reminder.ts: pure dispatcher module with three pinned-down
entry points so the unit tests don't have to spin up pg-boss:
* `evaluateMoodReminderWindow` — boundary contract for
"21:59 → skip, 22:00 → fire, 23:00 → skip" across any IANA tz.
* `buildMoodReminderPayload` — locale-aware title/body
("Stimmung erfassen" / "Wie geht es dir heute?" for DE,
"Log your mood" / "How are you feeling today?" for EN; falls
back to the app default for null/unknown locales).
* `runMoodReminderTick` — orchestrator that pulls opted-in
users, applies the window + already-logged + already-
dispatched filters, reserves the dedup row, and only then
calls the dispatcher. The P2002 unique-constraint violation
is caught and reclassified as "already dispatched" so a
lost race doesn't pollute the wide-event error stream.
- reminder-worker.ts: registers the `mood-reminder-check` queue
with a 15-min cron, mirroring the medication-reminder cadence so
any user's 22:00 boundary is caught within 15 minutes of wall-
clock without one-cron-per-zone. localConcurrency=1 keeps a
worker from racing itself. Also enriches the existing med-
reminder dispatch with a `scheduledAt` ISO string so the iOS
notification handler can pin "snooze 15 min" against the actual
schedule slot rather than the wall-clock moment APNs delivered.
- messages/{de,en,es,fr,it,pl}.json: new `moodReminders.dailyTitle`
+ `moodReminders.dailyBody` keys for all six locales.
- mood-reminder.test.ts: 13 unit tests covering opt-in gating,
window boundaries (21:59 / 22:00 / 23:00 + non-Berlin DST),
logged-today skip, ledger-already-exists skip, lost-race P2002
handling, and the multi-user multi-locale fan-out.
|
||
|
|
a550031a84 |
chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate cascade closure. - /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket sparkline + 60 s response cache. - Coach API gate inventory test surfaced and closed two orphan routes (chat[id] GET+DELETE, chat-message feedback POST). - Cross-tz fast-path runtime guard falls back to live for users more than ±3 h from UTC; proper bucket minting stays v1.5. - es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus three medication-cluster placeholder restorations. - IANA timezone validation closes the User.timezone self-DoS regression the new dashboard SQL would have surfaced. - Unit suite 4520 → 4524. See CHANGELOG.md for the full set. |
||
|
|
88028fa082 |
v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics route (correlations + healthScore + bp_in_target on the rollup- coverage probe, 111s cold worst-case down to ~1.5-3s on power-user accounts), consolidates Apple Health step samples into a one-row- per-day list view with chevron drill-down and a nightly drain (~50x table compression once it converges), restores IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every Coach affordance behind a single feature gate with an invariant test pin, lifts the medication-detail Mounjaro card to byte- equivalent parity with Ramipril, brings the Insights overview hero row to height parity, hardens getClientIp behind a new TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card on Settings -> Export, adds the Medikamenteneinnahme quick-add overlay to the dashboard, drops the timezone override toggle for a silent browser-zone auto-seed, and fixes the v1.4.36 red CI workflows. Documentation refreshed across all three repos with five new architecture diagrams in the Dracula palette. Unit suite 4354 -> 4486 passing; typecheck + lint clean. |
||
|
|
5a3ca330ac |
v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation release. Self-hosters no longer need to run scripts/backfill-rollups after upgrade; the worker boot discovers any account with measurements but no rollup coverage and enqueues a one-shot full fold via pg-boss. New rollup-full-backfill queue + handler folds one user per job at serial concurrency. enqueueBootTimeRollupBackfill runs once at startReminderWorker boot, idempotent across reboots via the discovery query (matches only zero-rollup accounts) and a singleton-key per user so fast restarts coalesce. Best-effort: discovery errors surface through the helper's return value and are logged via workerLog; the worker boot never fails because the backfill missed. Tests: 4285 unit + 230 integration. tsc + lint clean. |
||
|
|
8dc9edb65e |
v1.4.35 — persistent measurement rollups + partial read-swap
Foundation release for the persistent measurement_rollups cache tier. Adds the additive table + enum, populates it from every measurement write hook, lands the Apple-Health-import end-of-job backfill, and ships the parity-checked read-swap on two reader surfaces. The comprehensive insights aggregator and the slim analytics summaries slice now source count / min / max / mean per type from the DAY rollup buckets via aggregateBuckets, plus the comprehensive dailyByType correlation feed straight from the bucket means. Slope / R² / standard deviation / anomaly counts stay on live SQL — they do not compose across DAY buckets. A defensive parity check against the live aggregate's COUNT(*) falls back to live SQL when divergence is detected. Includes the new scripts/backfill-rollups.ts CLI for the one-shot fold of historical measurements into all four granularities. Tests: 4280 unit + 228 integration. tsc + lint clean. Migration 0067_v1434_measurement_rollups is idempotent under re-run. |
||
|
|
f5962497d6 |
v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
|
||
|
|
affede31a4 |
v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
|
||
|
|
4f09c3f862 |
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release. |
||
|
|
17b8d8d2c2 |
chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta. |
||
|
|
05c7f1474d |
feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|