The drill read only the alphabetically last object of the newest date, so
one account stood in for all of them. It now checks every object of that
date one at a time, records a failure against the account it belongs to,
keeps going, and pages once per failing object with its key and how many
accounts were checked.
A text column written before key ids existed holds the bare base64 of
nonce, tag and ciphertext. The key check read such a value as an encoded
Bytes column, decoded it once too often, and accused the v1 key of not
opening a copy it opens. The instance's stored AI key has exactly that
shape and is the shortest v1 value in every disaster-recovery copy, so it
was picked as the probe and refused every restore of an account with v1
values, even with the instance settings left out of the verdict.
A legacy value now reads as a string unless its decoded head is base64
text. Samples are kept per section and only the sections the verdict
counts are probed; a key is unreadable when none of its probes opens.
Binary values open with the label their column is sealed under, taken
from the encrypted-column registry, and are not probed when no label is
known. A preview stored by an earlier release is still read, and its
sample's layout is derived again from the value.
Day keys and wall-clock fields are computed per row in the analytics,
digest and personal-record loops, and each call ran
Intl.DateTimeFormat#formatToParts plus a resolvedOptions() allocation
to re-validate the zone. In a CPU profile of an Insights page load the
zone helpers were 17-19% of the busy time on the request thread.
Probe the offset once per UTC hour at both ends of the hour and derive
the wall clock arithmetically; an hour that contains a transition, an
instant outside 1971-2199 or an invalid date keeps the formatter path.
Zone validity is memoised per name. userDayKey drops from 9.3 to 1.5 us
per call, wallClockInTz from 6.4 to 0.5 us; a parity test checks the
result against Intl around every 2026 transition in ten zones.
The health score card passed the episode onset, an ISO instant, straight
into its sentence, so it read "Rest Mode active since
2025-08-04T09:55:00.000Z". It now shows the day that instant falls on in
the profile timezone, in the user's date format. The test that pinned the
raw value now pins the formatted day.
probeRollupCoverage runs ahead of the dashboard snapshot, the analytics
slices and the comprehensive insights. It read every live row of the
account for SELECT DISTINCT type and counted every DAY bucket per type,
which the planner served with a sequential scan of measurement_rollups.
Walk the distinct types with a recursive CTE along the live index and
test coverage with EXISTS. On a copy of a long-lived account the probe
drops from 24-50 ms to under 2 ms and returns the same map; an
integration test keeps the old query as the oracle.
A production build loads src/lib/db.ts once per server bundle, and the
client was only parked on globalThis outside production. The running
process held three clients: about 28 MB of query-compiler state each on
a heap capped near 520 MB, and three pg pools each sized to the full
per-process connection budget. Park the client on globalThis in every
environment so each copy of the module resolves the same one.
Recharts sizes every tick and label by writing it into one off-screen
span and reading getBoundingClientRect(). Under <body> each read forced
a document-wide style recalc, because the stylesheet's descendant-subject
:has() rules leave Chromium no way to scope the invalidation. A seeded
dashboard made 149 such reads per cold load at about 2.8 ms each, about
1.8 s of main-thread time under a 4x CPU slowdown.
The chart runtime now parks that span in a zero-size, contain: strict
host under <html>, which Recharts finds by id and keeps using. Each read
drops to about 0.01 ms; the dashboard's total blocking time under the
same slowdown falls from about 2.5 s to about 0.75 s. The host copies
<body>'s classes, where the app font is set, so every string measures
exactly as before; an e2e spec checks that parity on the drawn labels
and that the host survives a client-side round trip.
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.
Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
The rail animates its width from w-16 to w-64, and the labels laid out
against the growing width: every row overran the nav and multi-word
labels wrapped, so the nav's overflow-y-auto painted a vertical and a
horizontal scrollbar for the length of the transition.
Lay the expanded column out at its final width from the first frame and
let the rail clip it. The final layout is unchanged; the column fills
the expanded rail exactly. A unit test pins the structure and an e2e
spec samples the nav on every frame of a real expand and collapse.
Recapture Insights, resting heart rate, Medications and Coach at the
same 2552x1308 frame. The resting heart rate shot no longer shows the
raw metric enum in the chart legend. Images are palette-quantised, about
a fifth of their previous size.
The note had one form per locale, so a single left-out record read as a
plural. It now resolves One, Few and Other through tCount like the other
counted strings, with the singular in every locale and the Polish few form.
A sample the app wrote before the origin marker existed still carries the
HealthLog row id as HKExternalUUID. A hit on one of the account's rows of
the same kind now leaves it out exactly, for Withings and import mirrors as
well as manual ones; either id of a blood pressure pair covers both halves,
since the app stamps both samples of a reading with one id.
The manual-mirror lookup read one OR clause per row on every batch, even
for an account without a single MANUAL row. The types that hold MANUAL rows
are now read once per import; a batch asks nothing for any other type, and
one time window per type for those.
Cycle samples carry the marker too and are now left out like the rest, and
a sample the export repeats (a correlation's records also stand on their
own) is counted once.
The comment claimed the app's own sync filters by the marker. It does not:
the app's echo check is HKExternalUUID plus the sample's authoring source
being the app itself, and the marker exists for the deletion path, where
the source is not visible. Say so, and say why the export import reads the
marker and the row id instead.
The wide tier let the trend column grow to 8rem while the sparkline it
holds is a fixed 72px glyph, so at 1920 px an empty 56 px strip opened
between the sparkline and the chevron. The column now stays 72px in both
tiers; the extra width goes to the range bar and the name, as before.
The wide-list spec reads the resolved grid tracks and asserts the trend
track is 72px at every width it covers.
The systolic and diastolic reads each took the ladder's source per day on
their own. Whenever a source held only half of a reading on a day (a deleted
diastolic, an orphaned sample, a sub-floor systolic), the two picks diverged
and the pairing joined one source's systolic to another source's diastolic.
A sub-floor systolic could also claim a day and then contribute nothing,
leaving the day empty although a lower-ranked source had a valid reading.
The source is now picked per day from the systolic rows that pass the floor,
both halves are read from it in one statement, and a pair is only made
within one source, which also covers a reading that straddles midnight.
src/app/favicon.ico was still the filled heart from the first release on a
solid white square. Next serves it next to favicon.svg, and browsers that
pick the ICO (Safari, bookmarks, history, some tab states) showed the old
icon on a white slab. It is now the current logo at 16, 32 and 48 px with a
transparent background.
A structural test lists every file that takes a database or in-memory mean
over measurement values and names pulse, and requires each to use the shared
statistic, hand the type to a fold that does, or state why it never averages
pulse readings. A new plain mean over pulse, or a reader that drops the type,
fails it.
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.
A blood pressure reading is a <Correlation> wrapping its systolic and diastolic
records, and the marker can sit on the correlation instead of on each record.
The import only read it from a record's own metadata, so a marked correlation
would have come back in full.
A correlation's records are now held until it closes, then all kept or all left
out together: the marker on the correlation, before or after its records, or on
a record, applies. Tests cover each, the marker not spilling onto the next
record, and a self-closing record.
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.
The blood-pressure branch of /api/measurements/series still read systolic and
diastolic with an unranked findMany, so with a second provider the pairs blended
both sources and a reading present in both showed twice, while the other kinds
already follow the ladder since the dense and raw reads were moved onto the
shared helper.
Each of the two types now reads the ladder-winning source per day through the
same helper. The physiological floors (systolic 40, diastolic 20) and the
five-minute pairing are unchanged.
Rows written before this change keep the reading mean: the boot backfill and
the freshness check fill only missing days, so nothing rewrites them. Migration
0373 recomputes the mean of every stored PULSE row from the readings, DAY as
the mean of its hours' means and WEEK / MONTH / YEAR as the mean of their days,
and leaves count, min, max and the sums alone. The writer stores the coarse
rows the same way, so a refold and the migration agree.
The fixture is invented, in the shape Apple's DTD documents, and says so in its
header and its file name. It shows the parser reads a file laid out like an
export; it cannot show that Apple writes a custom metadata key verbatim.
A day of pulse is the mean of its hours' means and a window the mean of its
days, but only the rollup tier and the chart knew it. The helper gives every
other reader the same statistic in both shapes it needs: in TypeScript over
readings it holds, and in SQL as a per-reading day weight whose sums give the
mean of the day values over any window. Every other type keeps its plain mean.
On a wide window the compact lab list was one five-column row whose only
flexible column was the name: the name column sat mostly empty while the range
bar and the trend stayed small at the right edge.
The list's card is now a container, and the columns follow its width instead of
the window's. Below 64 rem of card the row is the lg table it was, with the
range bar at 12 rem; from 64 rem the bar column grows from 12 to 18 rem and the
trend column from 72 px to 8 rem, and the bar fills its column. The two tiers
exclude each other, so the result does not depend on the order the CSS is
emitted in. The trend sparkline itself is unchanged.
From lg up a long analyte name wraps instead of being cut off; a phone keeps the
single line with an ellipsis, and the bar keeps its own 12 rem cap there.
The shared 1280 px frame is untouched; a Playwright spec measures it on /labs,
/measurements and /mood at 1280, 1920 and 3440 px.
Fixes#1057
The iOS app writes manual entries into Apple Health, and mirrors Withings and
import rows into it, each stamped with dev.healthlog.app.origin = healthlog.
The server already holds those readings under their own source, so the same
sample arriving through an Apple Health export is a second copy: the series
shows it twice and the rollups count it twice.
The export import now leaves out a record carrying that marker. The marker is a
child <MetadataEntry>, read after the record's open tag, so a record is
committed at its close tag. As a fallback for manual entries mirrored before the
app set the marker, a record is also left out when a MANUAL row of the same
type and value sits within 2 s (same rule and tolerance as the sync path).
Withings and import rows are not matched by value and time: they reach Apple
Health only through the mirror, which always sets the marker.
The import summary says how many records were left out for this reason.
Fixes#1109
An endpoint that does not know reasoning_effort answers with an unknown
parameter error, which the JSON-mode probe read as a refusal of
response_format and switched that base URL to plain prompts.
One select under the model field of the Local and OpenAI-compatible
provider forms: Default, Off, Low, Medium, High, with a one-line hint
that Off leaves the answer budget for the answer itself. It reuses the
NativeSelect, label and hint layout of the field above it, seeds from
the stored entry, and saves with the form. Strings in all seven
locales.
Refs #1126
A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.
- Every exported resolver binds the entry's value onto the client, the
connection test included, keyed on the instance type so the operator
provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
compatReasoningEffort and GET reports them. A provider absent from
the chain holds its value on a disabled entry; restating Default on a
chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
openai-compatible entries, refuses it on any other, and keeps the
stored value when the body leaves it out. Its schema is now shared
with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.
Refs #1126