Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.
Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.
The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.
scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.
The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.
Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
The briefing prompt carries the server-computed progress line of up to
two active Coach plans, fenced as data because it quotes the person's
own goal words. The model may mention one and may not invent one; the
figures in the lines are admitted by the grounding gate like any other
figure the server handed over. The lines join the snapshot fingerprint
only when there is one, so an account without an active plan keeps its
stored fingerprint, and a failure to build them drops the lines, never
the briefing.
runStatusCompletion and runBriefingCompletion take an optional
reasoningJob. Only a named job carries reasoning, resolved against the
operator's switch and cap and the payer; a call without one, which is
every status card, batch note, derived score and workout line, sends
exactly what it sent before. Thinking is reserved on the day's ledger
with the answer budget, and a reasoning status call falls back to a
90 s ceiling instead of 60 s.
The briefing reasons on its generation and phrasing re-roll, the weekly
and monthly narratives on their first pass; the JSON and grounding
repairs do not. An inventory test freezes which files may name a job.
The check that keeps an earlier day's briefing off the Today surfaces
read the cache row's insightsCachedAt. Every warm moves that column,
including the unchanged-hash short-circuit that keeps yesterday's text
and only refreshes the stamp, so text written the day before passed as
today's after the nightly warm.
The cached payload now carries briefingGeneratedAt, written only where
new text is written: a full generation in the warm and in the
on-demand route, and the daily paragraph re-roll. The stamp refresh
leaves it untouched. briefingForToday reads that moment, and a payload
without it counts as not from today, so the hero falls back to its own
lead until the next generation.
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.
- computeSignalsOfDay drops a metric with no reading today, so the
briefing prompt and its content hash never see a stale value as the
day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
drops a reading-backed signal whose metric was not measured today,
so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
coincident flag and the health status deviations use the new
isFromToday rule (calendar day, not elapsed hours).
Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
The same-hours standing averaged each day as sum / n. Pulse already folds
its day as the mean of its local hours' means, and the day-aggregate reader
returns that as dayMean; a type moved onto that reader would have been
compared raw. The standing now reads dayMean where the reader carries one.
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.
A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
The digest is polled every 120 seconds. Its cycle line read the whole
cycle history, 90 days of day logs and wrist temperature on every poll,
outside any cache, and the coincident-deviation engine walked its eleven
vitals one after another, two queries each.
The cycle read now rides the digest extras cell, which every cycle write
already hard-evicts; the cycle preferences route evicts it too, and a
module switch does through the health-score invalidator. A record without
a cycle profile stops after one point read. The per-vital reads run
together and are folded in registry order, so the result does not depend
on which finished first.
On a seeded four-year account (1.25M measurements, 52 cycles) under a
792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine
from 11.1 to 4.6 ms (medians of four interleaved rounds).
The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.
coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.
Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.
The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.
The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.
Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
get_metric_series (and get_metrics, the metric resources and the review
prompts, which fan out over it) answered with one section of a Coach
snapshot, but built the whole snapshot to get it: the all-type feature
extraction, the derived wellness layer, trajectory, correlations, memory,
GLP-1, cycle, illness, labs and visits. On a seeded account with about
215k measurements a resting heart rate read ran 208 SQL statements and
blocked the event loop for 175 ms to return under 2 KB.
The MCP surface now asks the executor for a source snapshot. It runs the
same prelude (prefs, module gates, admitted sources, cutoffs, now shared in
snapshot-prelude.ts) and the same block builders over the requested
source's reads only. The core clinical aggregates come from extractFeatures
scoped to the one block, whose bulk read keeps its all-type cap so the rows
a block sees do not change. A section large enough for the full builder's
prompt-budget pass to trim, and the environment cluster, go to the full
builder, so their results are the old ones by construction.
The integration test compares the tool's serialised result with the full
builder's section and grounding for nine sources over four windows, the
get_metrics batch and the metric resource, on a seeded account dense enough
to put the feature row cap into play.
The insights comparison snapshot took its current and baseline pulse figures
from a reading AVG, and the Coach's data inventory told the model a reading
mean of the whole pulse history. Both now read the mean of the user's local
days, each the mean of its hours' means, through the day-mean helper. Every
other type keeps its aggregate.
The feature extraction, the signals of the day, the status cards, the graded
series, the Coach snapshot, the mood correlations and the report's trend arrow
each still averaged pulse over its readings, so a dense workout hour decided
the day and a workout day the window. They now read a pulse day as the mean of
its hours' means and a window as the mean of its days, through the day-mean
helper. Counts, extremes and spreads stay over every reading; every other type
is unchanged.
The doctor report's pulse average and day points, the correlation channels,
the illness correlation, the personal baseline and the Coach's tiered context
each folded pulse readings into a day by plain mean. They now take the day as
the mean of its local hours' means and a window as the mean of its days, in
the zone each reader buckets by. The day aggregates carry the weighted sums for
an hourly-mean type, so every caller reads one day value. Every other type is
unchanged.
The dashboard summaries, the insights aggregate and the series stats strip
still took a plain mean over pulse readings for their 7-day, 30-day, prior-month
and all-time figures, while the year-ago figure and the chart already weighed
each day once, so the year delta compared two different statistics. They now
go through the day-mean helper: each day is the mean of its hours' means and a
window the mean of its days, in the frame each reader buckets its days by.
Count, min, max, the median and the spread stay over every reading; every other
type is unchanged.
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
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.
- Migration 0368 adds medication_courses and backfills one course per
medication window (an end-only window starts on the creation day, or
on its end day when that lies earlier); a medication without a window
stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
.../courses/{courseId} (owner-only); create seeds the first course
from a window; the medication PUT edits the latest course and refuses
a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
between them; every compliance read selects the courses. A rolling
cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
the detail Lifecycle group lists the courses with start, edit and
delete. The Coach compliance block, the MCP resource, the doctor
report window and table, the clinician view and FHIR (one
MedicationStatement per course with effectivePeriod and status) read
the courses.
- Backup carries courses per medication and the restore writes them and
re-projects; an older file derives the one course its window
describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
The body fat tile was the one strip tile that led nowhere. Its href map
said there was no dedicated sub-page, which was true: body fat percentage
was tiled and charted on the dashboard but never got the Insights page
every body-composition sibling has.
Add /insights/body-fat on the generic metric-page scaffold (body group,
tab-strip pill, seven locales, manual-entry empty state) and link the
tile to it.
Move the tile href map out of the page into tile-destinations.ts, keyed by
the tile-capable widget id and typed against the sub-page slug union, so
a missing or non-existent destination fails to compile. The link passes
the destination's insights-page surface gate, so it never points at a
page a switched-off module hides.
A guard derives the tile set from TILE_CAPABLE_WIDGET_IDS and from the
ids the dashboard actually pushes into its strip, and fails when a tile's
metric has a sub-page the tile does not open, when a destination has no
route, or when a tile links to a page owned by another module. Tiles that
deliberately lead nowhere need a written reason; there are none today.
The reverse-lookup test pinned BODY_FAT as having no page; it now
resolves to body-fat.
Refs #1084
The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.
A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.
Refs #1067
The weight status note had its own generator and never took the unit
preference: its signal, summary, graded series, latest-day figure,
target band and correlation pairs were all in kilograms, and the card
prompt stated the normal day-to-day swing, the plateau line and the
loss pace in kilograms too. A reader on pounds was handed both units.
The generator now converts every weight figure once after the canonical
arithmetic (the correlations are scale-free and stay as computed), the
card prompt states its thresholds in the reader's unit, and the unit
preference joins the input fingerprint so a switch is never answered
with the old note. The shared status-card examples and the briefing's
plateau pattern no longer show a weight in kilograms for the model to
copy.
Refs #1067
The daily briefing serialised the aggregated feature set as computed:
weight, grip strength and the weight target in kilograms, waist in
centimetres, glucose and the signals of the day in mg/dL (the glucose
signal carried no unit at all), workout distances in kilometres. A
reader on pounds or mmol/L got a briefing in units they do not use.
One step now converts the feature set after all canonical arithmetic,
on both the nightly and the on-demand path: absolute values through the
full transform, differences and slopes through the factor alone, every
converted block labelled, workout distances under unit-neutral keys.
The prompt, the stored fingerprint and the grounding check all read the
converted set, so a unit switch moves the fingerprint and the grounding
check compares the reply against the figures the model was shown. The
weight target sentence, the comparison block and the GLP-1 plateau
context quote weight in the reader's unit; the comparison block also
labelled sleep as hours while carrying minutes.
A unit switch now also re-warms a cached briefing alongside the status
notes and narratives.
Refs #1067
The acute red-flag clause stated the glucose floors only in mg/dL and the
sustained-fever floor only in °C. The clause is matched against what the
person writes, so a reading of 2.9 mmol/L or 103 °F did not meet any of
its numbers. Each floor now carries both units, mg/dL and °C first, in
the English and German contracts.
Refs #1067
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.
- requireFloor(label, found, floor) throws below the floor and refuses a
floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
and floors both the files read and the hits found.
Floors added, pinned at the current counts:
- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
(399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
are equal.
The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
`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.
endsOn is a calendar date, and the storyline compared it with the instant
now, so a course dropped out on its final day. Compare it with the user's
own today as the column's date value instead.
The narrative, the workout distance and the elevation chart converted
through the unrounded escape hatch, and the trend caption scaled its
noise floor off the raw factor. They now take the rounded helpers, so a
narrated pound or mmol/L figure reads at the grain every screen shows
it, and the caption's floors are declared canonically (the distance
floor in metres). The trends row joins the chart-feeding surfaces that
may hand a raw scale to a chart.
Refs #1067
Two more surfaces carried a fixed unit. The insights trends row labelled
weight in kg and body temperature in °C for every reader, and plotted
walking distance as raw metres under a km label; its trend caption
said the same. A single transformed series there now takes unit, scale
and offset from the preference, and the caption describes the converted
series against a converted noise floor.
The illness day sheet and timeline showed and took a fever in °C. The
fever is now seeded, typed and shown in the reader's temperature unit
and stored in °C as before.
Refs #1067
The period narrative pinned kilograms and mg/dL on its change list, and
its personal-range lines carried no unit at all. Every delta and band
edge is now stated in the reader's units (computed canonically, converted
at the end, percent left unit-free), and each band transition carries
its unit into the prompt. The sleep channel, a per-night total in
minutes, was labelled hours and is now converted to them.
Refs #1067
The per-metric status note quoted glucose in mg/dL, body composition in
kg, temperatures in °C and walking distance in metres, whatever the
reader had chosen; the deterministic floor without a provider did the
same. The generator now converts the series, the reference band and the
signal into the reader's units before they reach the prompt or the
floor, and prints the guideline band table in that unit while still
classifying on the canonical value.
A changed glucose unit or metric/imperial preference (own account or a
managed record) now re-warms the stored notes and narratives through
the hash-gated refill, so today's text does not keep the old unit.
Refs #1067
The table shows its caption, scoped headers, right-aligned tabular
numbers and a dash for a missing reading, twelve rows before Show all.
Copy writes TSV and an HTML table together, falling back to TSV, with
formula starts neutralised and separators flattened inside cells.
Building the coarse buckets from the DAY tier read every day of the window
into the process: a five-year yearly read moved thousands of rows per type,
and the Coach's history bands read several such windows per request. The
per-day source pick and the fold now run in Postgres (DISTINCT ON the day,
ordered by the ladder rank and then the source name, the same order the
in-process pick uses), so a coarse read returns one row per bucket. The
long-range chart picks its tier from the span of the window's days and reads
that tier once. The Coach snapshot hands its already-loaded source order to
the history bands instead of each band reading it again.
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.
"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
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.
Today's key, each new record's day and the streak series were UTC days, so
through a New York evening a best set that afternoon counted as yesterday's
and dropped out of the digest. All three are now the user's own days; the
streak series is folded live on local days for this read.
The rollup writer keeps one row per bucket and source. Collapsing the stored
WEEK / MONTH / YEAR rows picked one source for the whole bucket, so a month
of manual blood-pressure readings plus one synced reading reported the synced
day alone, and a step month that switched devices lost every day the losing
device carried. The live weekly and monthly chart fold made the same pick.
Every coarse bucket is now folded from the canonical DAY buckets: the source
ladder picks one source per day, then the week, month or year counts every
one of its days. Spread, slope and r² are composed from the summed day
accumulators. The live fold picks per local day before folding into weeks
and months. Ties between sources without a ladder rank break alphabetically
on the SQL paths too, matching the rollup reader.
Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.