Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.
What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.
The digest still carries the arrival, which the morning line and the
wide event both read.
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.
So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.
That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.
A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.
The score's foot disclosure gains one link into the page.
Two sentences about the Health Score stopped being true the moment
someone could choose which pillars count toward it.
The one people read said the score averaged "every pillar that has
enough recent data". For an account that took a pillar out, that
describes ground the number does not cover, and it reads as a
reassurance rather than a description. It now says the average is over
the pillars that count, that a pillar without recent data is still left
out rather than zeroed, and that which pillars count is the reader's own
choice, so the combined number is not itself a clinical standard. All
six locales.
The one nobody reads named the WHO HEARTS technical package flat, as
though a standards body had signed off on whatever selection the account
holds. HEARTS names the risk factors the pillars are drawn from; it
prescribes no average over them. The entry now says what it is cited
for. While checking where that citation surfaces it turned out it
surfaces nowhere: ProvenanceExplainer takes the prop and renders only
the method caption, which v1.22 did on purpose when the info popover
went away. That is written down in the file now, because the map's own
header implies otherwise and the next person would trust it.
The registry entry for HEALTH_SCORE lists all eight pillars with a
minimum of three, which reads like the recipe and like a threshold. It
is neither. The published coverage and confidence are built in
computeComposite over the resolved composition, so a person counting
four pillars is not measured against a denominator of eight, and the
real floor is the breadth rule, which counts distinct domains rather
than inputs. Both facts are now stated at the entry instead of being
inferable only by following the call chain.
Tests: the published denominator is never the eight-pillar catalogue; a
pillar somebody took out of their score is never listed back as missing
coverage; a pillar they kept and have no data for still is; the registry
list stays equal to the score catalogue; the choice sentence is present
in all six locales.
One line under the method version, shown only when the server says the
composition is the person's own. It states the fact and stops there:
which pillars they took out belongs to the settings surface, and
working it out here from a configuration blob would put a second
decider behind one sentence.
Changing which pillars count is an act the person performed, not
something that happened to their health. Until now the week-over-week
comparison could not tell the difference: both windows are computed in
one request under whatever recipe is in force right now, so the pillar
sets agree, the subtraction looks legitimate, and the dashboard could
announce "your score dropped 14 points" to someone who had just come
back from the settings page.
The recipe now carries its own dated identity, and the delta guard
compares against it the same way it already compares against the global
method boundary: a recipe that changed between the two windows
suppresses the delta with a new reason, config_changed, and the surfaces
that gate on an unexplained delta stay quiet. The score record stores
the recipe version and its change date on every write, resolved from the
same stored blob the composition came from, so a row can say which
recipe produced it. A new series module reads those versions back, marks
the first day after a change as a break, refuses to join the composite
line across it, and keeps the per-pillar rows running through it: a
pillar is graded against its own reference band and stays comparable
when the average of the pillars does not.
The notice key folds the person's recipe version in beside the method
version, so a change in either raises the existing dismissible notice
once and dismissing one never dismisses the other. An account that never
chose keeps its old key untouched.
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.
The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.
The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.
The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.
A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.
The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.
This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.
Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
apiErrors.profile.update.{nothingSaved,invalidBody} let the web client
resolve applyProfileUpdate's new errorCode into a localized sentence.
settings.profilePartiallySaved / profileNothingSaved carry a {field}
placeholder so the account settings screen can name the specific field
that blocked or was skipped, in the active locale, instead of a raw
validator string. All six locales.
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.
The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.
Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.
The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.
Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.
The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.
The detail page also drops the description it borrowed from the list.
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.
Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.
The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.
A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.
Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
The card carried a four-sentence non-medical notice at the top of the cycle
on-ramp. It goes, at the maintainer's decision.
The goal-specific caveat on the prediction surface is untouched: a profile set to
avoid pregnancy still gets the stronger "not a contraceptive method" copy, which
is chosen server-side by goal and shared with the native client, so nothing about
that path changes here.
Removes the key from all six locales and the now-unused icon import.
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.
The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.
The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.
Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.
Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.
Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.
Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.
Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.
"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.
"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.
And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.
The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
Four things an independent read found, three of them things this release said
about itself that were not true.
A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.
The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.
The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.
And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
The hint said switching a widget off here turns it off in the mobile
app. This page writes the flag; whether the client applies it on its
next read is the client's business, and there is an open report that it
does not. Say where the setting lives instead of promising a result
this surface does not control. Six locales.
BMI renders on the native dashboard and was configurable from nowhere.
Two individually-correct decisions produced it: `bmi` is a catalogue id
with no web render path, and the web layout editor filters those out
because a toggle over something this page cannot draw would be a silent
no-op. The widgets PUT accepts and persists them all the same, and the
native client writes them, so an account ended up holding rows it never
set and could not change from any surface.
The sortable list stays exactly as it was. Below it, the ids the web
does not draw but the layout carries get their own labelled group with
the same two flags and no reordering — placement belongs to the client
that draws them. An account with no native client has nothing there and
sees nothing. The module gate applies as it does above, fail-open.
Refs #581
A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.
Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
The switch was described three times as applying to what you upload
next. A genuine off-to-on flip also enqueues a pass over the documents
already stored without a summary, capped at 200 per flip — scanned
medical documents going to a third-party provider, from copy that reads
as forward-looking only.
Drop "newly uploaded" from the sub-label and give the disclosure the
sentence it was missing, including the cap. Six locales. The behaviour
is right and stays as it is; only the sentence changes.
A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving
the cap without touching the copy fails.
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.
Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.
The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.
A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.
Refs #659
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.
The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.
An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.
The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.
The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.
The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.