The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.
The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.
The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
A statement-level hang in the integration suite surfaced only as a
60-second test timeout, because statement_timeout sat at the same 60 s.
At 45 s the server cancels first, and its error names the statement and
the lock or wait it was stuck on.
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.
Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.
Refs #1313
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.
Refs #1311
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.
Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.
The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.
It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.
Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.
Refs #1340
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.
Refs #1342
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.
Refs #1333
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.
A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.
Refs #1333
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.
The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.
Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.
Refs #1333
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.
Refs #1342
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.
Refs #1339
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.
Refs #1340
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.
Refs #1341
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.
Refs #1338
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.
Refs #1334
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.
The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.
The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.
The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.
Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.
Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.
Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.
Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.
Card: connect errors are translated from meta.errorCode in every locale.
Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.
- Transport: key exchange with a per-process token cache keyed on the
key, one re-exchange on a refused token and then reauth_required, the
429 Retry-After carried to the caller, the China host tried when the
global one does not know the key, answers read with a byte cap, all
egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
activity search, chunked to each resource's documented range, read one
after another and stopped at the first rate limit; today's menstrual
cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
learns where the history starts from the daily summaries and the
activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
token. All four chart on the recovery page and sit on the ladders,
units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
from the validity picked at connect; the card warns a week ahead and
asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
encrypted-column registry, rotation script, OpenAPI and all ten
locales carry the new integration. The old "no Garmin connector" note
is gone.
Cyrillic is two bytes per letter in UTF-8, so the Russian catalog is
240 KB gz against under 210 for the Latin-script ones. The reason and the
numbers are in the budget file.
A rule with no occurrence (BYSETPOS=2 over one weekday) makes rrule walk
to its year 9999 on every call, seconds per case, which no schedule the
app accepts can produce. The reviewed counterexample is a fixed case.
Also formats the locale settings file.
The stat strip reads the chart's visible window, so with the chart
narrowed to the morning its figures were morning figures under a plain
Systolic label. The column label now says so. Russian texts for the
time-of-day card.
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").
Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.
The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.
The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.
`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.
Refs #1331