Insights gain selectable time ranges with period-over-period deltas, and the
mood page adds time-of-day, stability and weight/BP correlation reads. Medications
can carry ATC + RxNorm codes that flow into the health-record export alongside a
MedicationAdministration per acted dose. The connection pool is sized for real
concurrency to remove the under-load stalls, and the medication advanced-settings
page is rebuilt. The standalone /targets page is retired in favour of inline editing.
Adds a plain-language assessment to every HealthKit metric page via a
data-driven generator (archetype templates + per-metric metadata, no
per-metric prompt), a one-tap/auto background pre-generation of all
assessments, and the iOS-facing widening of the Insights layout to the
full metric set. Updates the ChatGPT Codex model-slug ladder so
assessments generate again after the backend rotated its identifiers.
Splits the Targets/Sources settings pages, renames the section to
AI Insights, and applies the metric-detail back-nav, dropdown padding,
heading, and Sources-translation polish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The medication card reads the schedule engine's next-due (fixes
interval/one-time schedules that never advanced after a dose), logging a
dose confirms itself, Insights status polling is bounded, each Insights
category explains itself inline, medication history shows each dose's
origin, and an idempotent migration adds the app_settings columns that
were only ever applied ad-hoc.
Per-category Insights assessments are served read-only from cache and
generated out of band, the client request is capped, the overnight warm
pass keys on the shown language, a stalled model is briefly remembered,
and Insights/dashboard stream a loading skeleton — so opening and
switching Insights categories stays responsive instead of freezing.
Guarantees a single intake entry per scheduled dose: intake writes snap
to the canonical slot instant and update in place rather than inserting a
sibling row, a re-sync can no longer clear a recorded dose, concurrent
logs converge, and a one-time startup pass collapses existing duplicates.
Closes the two surfaces the 1.7.3 multi-time compliance fix did not
reach: the today-tile projection now mints one pending dose per
scheduled time, and the compliance heatmap classifies each dose against
its nearest scheduled time instead of the first window.
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>
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).
14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both are Dependabot bumps that have been waiting on the queue; landing
them directly so the lock files stay coherent with the rest of v1.5.5.
knip: minor; docker/metadata-action: clean major (workflow change is
exactly the @v5 → @v6 string in two places).
Closes#209Closes#183
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.5 medication-scheduling work needs to model monthly /
quarterly / yearly cadences plus arbitrary `INTERVAL` patterns. The
existing string encoding caps the interval at 4 weeks and has no
representation for month-level recurrence at all. RFC 5545 RRULE is
the industry standard for this and the `rrule` package is the
battle-tested TypeScript implementation (used by Google Calendar
sync, EventKit translation layers, FHIR servers).
Pin the latest stable. The library's release cadence has slowed
since 2022; a future migration to `rrule-temporal` is worth tracking
once Node 24 LTS lands, but the existing surface covers HealthLog's
needs without modification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.5.1 added SESSION_COOKIE_SECURE env-var opt-out but the bundled
docker-compose.yml's environment whitelist didn't forward the variable
into the container. Setting it in .env was a silent no-op for the
running Node process. One-line addition closes the gap; default
behaviour unchanged.
v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.
New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true" — always set (pnpm dev fronted by HTTPS)
Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.
OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.
Default behaviour unchanged for HTTPS-fronted deployments.
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.
## Features
- **stats:* upsert** (closes#213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.
## Fixes
- **Compliance honour daysOfWeek + intervalWeeks** (closes#214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.
## Tests
- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.
## Docs
- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.
## Final verify
- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
Marc reported that mood entries logged inside HealthLog — specifically
via the iOS app — never reached MoodLog. The historic integration
polled MoodLog every 15 minutes and pulled new rows, but nothing
flowed the other direction. iOS users who tracked mood in HealthLog
ended up with one log per app and no overlap.
`src/lib/moodlog/push.ts` adds `pushMoodEntriesToMoodLog(userId,
entries)`. Best-effort reverse-sync push to MoodLog's new POST
`/api/integrations/health-log/mood` endpoint (shipped in MoodLog
v0.2.0). Same per-user moodLogUrlEncrypted + moodLogApiKeyEncrypted
credentials the pull side uses, same isPublicUrl SSRF guard, same
manual-redirect policy. Wraps every failure mode in a wide-event
warning so a network blip or a stale MoodLog deploy can never bubble
back to the user's mood-create request.
`POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk`
now fire the push as a fire-and-forget side-effect after a successful
create. Entries with source === "MOODLOG" skip inside the helper to
avoid an echo loop. Bulk push only includes rows that landed as
`inserted` — duplicates and skips don't retry a known failure.
Pull side filters echoes:
`syncMoodLogEntries` now skips entries where MoodLog reports
`loggedVia: "HEALTHLOG"`. Those are reflections of rows we just
pushed; re-importing them would flip the source column from the
original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and
double-count in any source-segregated dashboard. An `echoSkipped`
wide-event annotation lets the operator confirm the round-trip is
closing at one hop.
Cross-repo:
The matching MoodLog v0.2.0 release adds the inbound POST handler
this push targets. Both apps must deploy together; MoodLog deploys
first so HealthLog never hits a 405 / 404 on the new endpoint.
Tests: 9 cases in src/lib/moodlog/__tests__/push.test.ts cover the
MOODLOG-source filter, no-credentials skip, SSRF refusal, headers +
body shape on the successful path, 5xx + network-reject + 3xx-
redirect failure modes, and the tags JSON-string → array key
normalisation. Pins the contract the call sites no longer await so
future helper refactors can't silently break the fire-and-forget
integration. HealthLog suite: 5279 pass, lint clean, typecheck clean.
Marc reported the BD (Sys) tile rendering `13… mmHg` — the
systolic / diastolic pair `131/85` outgrew the `text-3xl` footprint on
the narrowest grid columns and the value-row's `truncate` ellipsis
chopped the most important part of the tile (the number itself).
The other tiles in the strip (weight, pulse, glucose, …) carry a
single value that never threatens the column-width budget on its own,
so they keep `text-3xl`. Only paired-metric tiles step down to
`text-2xl`; `131/85 mmHg` at `text-2xl tabular-nums tracking-tight` is
~84 px wide vs ~140 px for the narrowest 2-column grid cell — fits
with the unit and trend arrow at every viewport. `truncate` stays as
a defence-in-depth safety net for outlier values.
`cn()` arg ordering tightened so `tailwind-merge` keeps the explicit
`leading-none`. Tailwind v4's `text-3xl` / `text-2xl` carry their own
default `line-height`, and the last-wins dedup rule otherwise dropped
`leading-none` and broke the W20a across-tile baseline alignment that
`trend-card-baseline-alignment.test.tsx` pins.
Also fixes a pre-existing date-boundary test flake:
`src/app/api/medications/intake/__tests__/route.test.ts`'s
`reads the rollup tier when coverage is present` computed `todayKey`
from UTC components, but the route's `readMedicationCompliance` uses
the user's timezone (Europe/Berlin in the test fixture). At every
22:00 — 24:00 UTC window — once the Berlin clock crossed midnight —
the two computations diverged by one calendar day, the mock returned
data for the wrong day, and `today.scheduled` came back zero instead
of three. The test now derives `todayKey` from the same Berlin zone
the route uses.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
Marc surfaced raw `notifications.eventMoodReminder` and
`notifications.eventMoodReminderDesc` reaching the UI after the
v1.4.49.2 relative-time fix had landed. The narrow `{count}`-call
audit that drove v1.4.49.2 missed every dynamic key construction; the
exhaustive sweep below picked up 28 keys called from real code that
had never existed in any locale bundle.
Stale gaps + when each surface shipped without its strings:
- `notifications.event{Type}` and `Desc` (14 keys) — the per-event
rows on the `/notifications` settings page. The matrix was added
with v1.4.41 and shipped with only `eventColumn` translated;
v1.4.49 then added MOOD_REMINDER to the enum which is what made
Marc notice.
- `onboarding.welcome.{chrome × 7, slide{1,2,3}.{title,body}}` (13
keys) — the 3-slide welcome carousel added in v1.4.45. The
carousel rendered raw keys for every screen-reader label and
every slide's content.
- `measurements.loadError` (1 key) — the error branch in
`measurement-list.tsx` added in v1.4.44.
All 28 keys × 6 locales (de / en / es / fr / it / pl) = 168 new
entries, all written in Marc's voice, ASCII-safe with proper umlaut /
accent handling per locale, no AI-mention or internal-tooling words.
Inserted at the END of each namespace so the diff stays surgical and
the integrity test (parity across locales) stays green.
Regression guard added at `src/__tests__/i18n-call-site-coverage.test.ts`:
walks every `.ts` / `.tsx` under `src/` (excluding tests + generated),
extracts every literal `t("ns.key")` with comment-aware parsing (line +
block + JSDoc comments stripped so docstring pseudocode is not flagged),
and asserts each key resolves to a string leaf in `messages/en.json`.
`i18n-locale-integrity.test.ts` then propagates the EN guarantee across
every other locale via key-set parity.
Why this gap was not caught earlier:
The v1.4.27 B6 `i18n-drift-guard.test.ts` carries hand-curated key
groups; surfaces added after that point (notifications matrix v1.4.41,
measurements list error v1.4.44, welcome carousel v1.4.45) shipped
without anyone editing the drift-guard groups. The integrity test
catches DRIFT across locales but not keys missing UNIFORMLY from every
locale. The new call-site test closes that hole — any t() call without
a backing key now fails at pnpm test time.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
Marc reported raw `insights.relativeHoursAgo` rendering verbatim across
medication cards, recent-achievements, admin sections, and the iOS
notification preview — anywhere `formatDateOrRelative` paints a
timestamp older than 60 s. The helper called `t("insights.relative
MinutesAgo", { count })` / `t("insights.relativeHoursAgo", { count })`
against a translation bundle that ships only the pluralised `*One` /
`*Other` variants; the i18n surface performs no auto-pluralisation so
the bare key bounced through `t()`'s identity fallback into the UI.
The fix mirrors the One/Other dispatch pattern that the v1.4.43 H6
sweep added to the canonical `src/lib/i18n/relative-time.ts:24-48`
helper. The format-twin in `src/lib/format.ts` was missed in that
sweep because the two helpers had drifted into byte-different siblings
since v1.4.20.
Audit confirmed the leak was isolated to these two keys: I cross-
checked every `t(…, { count })` call in the repo against
`messages/en.json`. The other 17 call sites (`insights.dayStreak`,
`medications.importDuplicatesSkipped`, `achievements.metricPercent`,
`passwordStrength.minLength`, `settings.integrationPill.*`,
`targets.*`, `admin.section.backups.uploadSuccess`,
`trendHints.remainingMany`, `dashboard.staleHint`, …) all use plain
non-pluralised keys that exist verbatim in the bundle.
Regression guard added to `format-date-or-relative.test.ts`: drives
every relative bucket through the helper, collects the `t()` keys it
emits, and asserts each one has a matching entry in `messages/en.json`.
Pre-fix this guard fails on the two bare keys; future twin-helper
divergence (e.g. someone adds a third date-formatting helper with a
copy-pasted body that lacks the pluralisation step) trips it before
it reaches prod.
Bundles v1.4.49.1's analytics rollup-tier delegation that the Coolify
auto-deploy never picked up.
Tests: 5269 unit + integration pass, lint clean, typecheck clean.
v1.4.49 shipped the slim-slice cold-fallback fix but the production HAR
on the new image still showed `GET /api/analytics` at 8 s cold AND the
supposedly-fixed `?slice=summaries` request hit the same 8 s when fired
concurrently. Investigation found the slim slice queues behind the
default slice's per-type live walk: the 15-way `fetchMeasurementSeriesChunked`
fan-out saturates the `p-limit(4)` lanes and packs the 20-slot Prisma
pool for the full ~8 s on Marc's 467 745-row tenant, starving every
other Prisma client (including the concurrent slim slice) until the
walk drains.
The default slice's `summaries` field has been produceable from the
rollup tier for two releases — the slim slice already uses
`computeFromRollups` + a 90-day narrow `$queryRaw` and returns the
identical `DataSummary` shape sub-second. The walk lived on because
two thick-only fields (`anomalyCount`, `avg30LastMonth`) only existed
on the live path. A grep of every consumer in the repo confirmed:
- `anomalyCount`: no consumer reads it from `/api/analytics`. The
insights pipeline pulls it from its own `comprehensive-aggregator`
narrow query. Slim's `0` constant is what the default route's
consumers already received.
- `avg30LastMonth`: read by `src/app/page.tsx:321` (dashboard
comparison tile) and `src/components/insights/vo2-max-chart-row.tsx`.
Easy to lift: extend the slim narrow `$queryRaw` with one extra
FILTER clause for `[30d, 60d)` — the rows are already inside the
90-day outer cap so the planner reads zero additional blocks.
With both fields covered, `buildAnalyticsResponse` calls
`computeSummariesSlice(user.id)` once and uses its `summaries` +
`lastSeenByType` directly. The 15-way fan-out, the chunked walker,
the `ANALYTICS_TYPE_FETCH_CONCURRENCY` p-limit cap, the 425-day
`since` window, and the `meta.analytics.bp_aggregate` annotate all
delete cleanly — none had callers outside the removed code path.
Net diff: -418 LOC across the route + its tests.
Test updates:
- `route.test.ts`: dropped the 130k-row PULSE stress test (was pinning
V8's spread-arg ceiling on the now-deleted JS-side loop) and the
`caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY`
test (the cap is gone). Added a negative assertion that
`prisma.measurement.findMany` is never called with the chunked-walk
shape `(select.id + source + deviceType, take: 5000)` and a positive
assertion that `avg30LastMonth` reaches the response envelope.
- `since-cap.test.ts`: dropped the `where.measuredAt.gte` per-type
assertion and the `bp_aggregate.live_since` annotation assertion
(both pinned the removed walk). The slim-slice no-per-type-loop
invariant survives.
Final: 5268 unit + integration tests pass, zero failures, lint clean,
typecheck clean.
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.
On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
v1.4.47.2/.3 confirmed the production APNS_KEY env-var is corrupted along the docker-compose env_file pipeline. Add APNS_KEY_B64 — operator base64-encodes the .p8 file and stores that ASCII-safe blob; app decodes back to PEM. No \n escape gymnastics. Precedence: B64 > KEY > FILE.
v1.4.47.2 normalisation didn't recover the production PEM. Emit a structured diagnostic on the existing parse-failure branch (counts + sha256_prefix, no secret bytes) so the operator can compare the in-container shape against the source .p8.
Coolify runtime logs surfaced
"secretOrPrivateKey must be an asymmetric key when using ES256"
on every push attempt. The channel auto-paused after 3 consecutive
failures.
Root cause: the .p8 PEM stored in the Coolify env-var arrived at
process.env.APNS_KEY without parseable newlines around the BEGIN/END
markers (env_file round-trip artefact). openssl pkey accepts it,
jsonwebtoken@9 used by node-apn is strict.
Normalise the PEM after the 12-factor \n unescape: strip whitespace
between markers, force 64-char line wrap, rebuild canonical PEM.
Idempotent on already-correct inputs. Verify via crypto.createPrivateKey
before handing to node-apn.
The narrows query inside computeFromRollups scanned the user's full
measurements partition without an outer measured_at cap even though
every aggregate FILTER inside the SELECT restricted to 7/30/90 days.
On a ~450k-row tenant the cold slim slice paid ~9s TTFB to read rows
that were always aggregated to NULL.
Adding AND m.measured_at >= NOW() - INTERVAL '90 days' to the outer
WHERE turns the read into an index range scan on
(user_id, type, measured_at) and returns bit-identical output.
Expected: ~9s -> ~0.5-1s cold, sub-50ms warm.
No schema change, no migration, no env-var change.
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 f18654f3).