readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
A watch samples pulse about every 5 to 10 minutes at rest and about every
5 seconds in a workout. The daily value was the plain mean over a day's
readings, so a workout hour of several hundred readings outweighed the other
23 hours: a day with one hard hour and 23 resting hours (60 bpm) read about
128 bpm, not 64.
A day of pulse is now the mean of its per-hour means, in the rollup DAY
bucket, the live per-day read the chart serves, and the series route's day
grain. Windows over several days weigh each day once instead of by its
reading count, so the bias does not return one level up. Reading counts,
min/max, and the regression sums still run over every reading. The chart
tooltip and the data table name the statistic, in all seven locales.
The statistic is limited to pulse; HRV, SpO2 and glucose keep the plain mean
(see src/lib/measurements/day-statistic.ts). Stored DAY rows are rewritten by
the next rollup recompute (admin recompute route), not on read.
Fixes#1107
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.
The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.
Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:
- cycle calendar, insights, Coach snapshot and prediction refresh start
their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening
49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
The current half of a cumulative metric's range delta counted today's
still-growing total, so the comparison read lower every morning. Both
halves now end on the last completed day: yesterday on the user's
calendar, and never a UTC day bucket that has not ended yet.
The all-time weight, blood-pressure and pulse figures the insight features
carry averaged every raw row, so a device that samples densely outweighed the
readings the source ladder prefers on the same days. They now keep one source
per day, like the dashboard's all-time figures.
The period-over-period range delta averaged readings. For step-like totals
the current window still holds the last day and a half as many small
samples, so the average collapsed; it now compares average daily totals. For
sleep it averaged stage segments and in-bed envelopes; it now compares
average nights of time asleep. The API descriptions say which.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
The v1.9.0 time-range work imported ANALYTICS_RANGES as a value into the
time-range pills and the insights layout-prefs hook from range-delta.ts.
That module also pulls the rollup readers and the Postgres client, so the
value import dragged pg (dns/fs/net/tls) into the browser bundle and broke
the production build with eight "Module not found" errors.
Move the client-safe pieces — the range list, its type, the per-range
window length, and the period-over-period result shapes — into a new
range-shared module that imports nothing server-side. The insights client
components import from there; range-delta.ts re-exports the same names so
the server route and the OpenAPI registry keep their import paths. The
window-composition logic and the rollup/db imports stay server-side.
No runtime behaviour change — a pure module-boundary split.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75124e76c1e987d8a022fb39d891049340199b38)
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.
New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.
The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)