Commit Graph
2687 Commits
Author SHA1 Message Date
Marc-André Bombeck cdfe4ec114 Merge work/score-settings-ui 2026-07-31 20:31:02 +02:00
Marc-André Bombeck f42d423edd Merge work/score-coverage-contract 2026-07-31 20:31:01 +02:00
Marc-André Bombeck 78fe4d8eea Guard the configured flag's meaning without a container
The flag was proven end to end over the real snapshot, digest and derived
wires, which is the right place to show that it arrives and the wrong place
to be the only proof of what it means. That suite needs a container runtime,
so on a machine without one the definition stood unguarded: replacing the
default side of the comparison with the unnarrowed catalogue, the exact
mistake the docblock warns about, left the whole unit suite green.

The definition now has its own fast test, and that break takes two of its
cases red.
2026-07-31 19:03:49 +02:00
Marc-André Bombeck bd4a6d14d5 Let a person choose what counts toward their Health Score
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.
2026-07-31 18:49:29 +02:00
Marc-André Bombeck 308908c226 Stop the score from describing a recipe the person may have changed
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.
2026-07-31 18:10:39 +02:00
Marc-André Bombeck c29055be1c Carry the configured flag onto the snapshot and digest wires
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.

The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
2026-07-31 18:03:53 +02:00
Marc-André Bombeck 5d7d00949c Resolve the configured flag on the score composite
One boolean on the composite: true when the account's resolved
composition differs from the composition its defaults would resolve to
today, both narrowed by the same modules. Decided once in the score
reader, from the same config and module map the composition came from,
so no client interprets a config blob to answer it.

This is not hasSelection. An account that opened the surface and kept
every pillar has written a selection and is not configured; an account
whose modules alone narrow the set has authored nothing and is not
configured either.
2026-07-31 18:02:22 +02:00
Marc-André Bombeck 0a524c542b Write down what an upgrade does to an account that already exists
The promise is that nobody's number moves the day they upgrade, and the
way it holds is easy to lose: there is no backfill and no migration
step, because a null column reads as version zero, version zero counts
every pillar, and every pillar narrowed to what the account's modules
record is exactly the set the module switches were producing before.
The inheritance is the resolver's behaviour, so the next person to touch
the resolver needs to know they are holding it.

Also records what the first save sets in motion, once each: the delta
suppressed while the comparison window still straddles the change, the
next stored day marked as a break so the line is drawn in two segments,
and the notice raised under a key carrying the recipe version.
2026-07-31 16:57:42 +02:00
Marc-André Bombeck 98eb609eb1 Remove two refusals that could not refuse anything
The recipe boundary opened with an early return for a missing date and
another for a date it could not read. Deleting either one changed no
answer any test or any caller could observe: both cases parse to NaN,
and every comparison against NaN is already false, so the refusal was
falling out of the arithmetic underneath them. An if that cannot change
an outcome is a comment wearing a keyword, and it invites the next
reader to trust a lock that is not attached to anything.

The behaviour is unchanged and now has one statement instead of three,
with the reason written where the next person will look for it.

The seam had the same shape in miniature: a day naming no recipe was
already broken away from a day that named one, by plain inequality, so
the arm only ever mattered when two consecutive days both named none.
That case is now covered, and removing the arm makes it fail.
2026-07-31 16:57:05 +02:00
Marc-André Bombeck 3406ed6946 Drop a boundary condition no input could reach
The recipe boundary also required a version of at least one before it
would suppress. That reads like a second lock and is not one: the
resolver returns a version below one only on the same path that returns
no change date, so nothing could ever reach the condition and no test
could make it fail. Removing it leaves the guard asking the one question
that has an honest answer, which is whether the recipe moved inside the
window.

The invariant that makes the removal safe is now pinned instead: a
resolved configuration never carries a version without the date beside
it. A resolver path that produced one would wave that account's recipe
change straight past the guard.

Also pins the order of the two rules that can fire together. A change
that moved the composition trips both, and the person is told they
changed what counts rather than that the included pillars changed, which
would describe the consequence and leave out the act.
2026-07-31 16:52:22 +02:00
Marc-André Bombeck 5d13f0e69c Prove the false drop cannot come back
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.

Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.

Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
2026-07-31 16:48:40 +02:00
Marc-André Bombeck 087f3f496b Stop a settings change from reading as a score drop
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.
2026-07-31 16:17:20 +02:00
Marc-André Bombeck 0826bd80e6 Merge the persisted score record into the composition swap 2026-07-31 15:57:37 +02:00
Marc-André Bombeck 0a877bdf4d Pin what decides the score's composition
The module-to-pillar mapping had no test of its own. It lived in
ternaries, so the only way to notice it had changed was for someone's
score to change.

The new file drives the real reader against a fake database and pins
three things. The composition for every module combination with no
stored recipe, compared against the old expression written out verbatim,
which is what makes it a before-and-after rather than a restatement. The
difference between a pillar that is selected and has no data yet, which
keeps its row, and a pillar whose module is off, which has no row at
all. And the breadth refusal at read time, whose reason has to be the
shared rule's verdict on the set the reader resolved, so a second copy
of that rule would show up as a disagreement.
2026-07-31 15:51:26 +02:00
Marc-André Bombeck 76e68f0247 Let the score's recipe decide what counts, not the module switches
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.

The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.

Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.

A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
2026-07-31 15:47:12 +02:00
Marc-André Bombeck 54fed25859 Silence an unused-parameter warning in the record write test 2026-07-31 15:39:24 +02:00
Marc-André Bombeck 5fd5447069 Make the band a database type instead of a text check
The delete-all-data guard seeds every table straight from information_schema
so a table added next year is asserted without anyone remembering. A CHECK
constraint defeats that: the generic seeder has no way to guess a value the
constraint accepts, and the whole suite failed on the new table. A Postgres
enum carries the same closed set, the seeder reads it from pg_enum, and
Prisma now types the column instead of trusting a string.

Dropped the day-key format and non-empty-composition checks with it. The
writer builds the key through the timezone helper and refuses an empty
composition before inserting, and the restore validates both against the
wire schema before the transaction opens, so a third copy in the database
bought nothing and cost a hand-written line in a fixture whose point is not
having one. The composite range check stays: free to check, and a number
outside nought to a hundred renders as a score nobody can read.
2026-07-31 15:38:50 +02:00
Marc-André Bombeck 956746fe7d Keep one sanitiser for pillar ids
The scorer and the stored composition both had to answer what a valid
pillar list looks like, and both had their own copy of the answer. One
function next to the catalogue now, used by both.
2026-07-31 15:37:04 +02:00
Marc-André Bombeck cd7c6f44c8 Teach the export test mocks about the recorded score days
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
2026-07-31 15:31:47 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 2f6f3573a2 Add the route that chooses what counts toward the score
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
2026-07-31 15:12:47 +02:00
Marc-André Bombeck d89b90da4b Hash what the score actually depends on, and nothing that cannot move
The fingerprint carried the composition twice: once as its own key and once
as the ordered pillar ids inside the per-pillar triples. No input could make
those two disagree, so the term could not be broken and the test that
claimed to cover it passed with the term removed. Dropped the duplicate and
replaced the test with one that pins what the ids are for: a different set
of pillars producing the same numbers in the same order is a different
standing, and now reads as one.
2026-07-31 15:07:35 +02:00
Marc-André Bombeck 4e649de4f7 Add the health-score composition resolver
Reads the stored deselection list into the pillar set the scorer and
the settings surface both work from. An absent list means the person
never chose and keeps counting everything; a present list is an
authored recipe and is never widened. Ids outside the catalogue are
dropped on read rather than failing the whole blob, so one unknown
string cannot discard a recipe someone wrote.
2026-07-31 15:06:55 +02:00
Marc-André Bombeck 860a1fa299 Record each local day's score on the path that computes it
Every surface that shows a score now goes through one entry point that
computes it and writes the day down, rather than three surfaces each
remembering to. The write is an insert that yields on conflict, so a second
computation of the same day leaves the first row alone, and a failure is
annotated rather than thrown: a score that renders must not disappear
because a bookkeeping insert did.

The fingerprint covers exactly what the stored composite and band depend on
- the algorithm version, the composition, and each counted pillar's score
with its scoring-mode identity. A pillar that was graded but not counted
stays out of both the fingerprint and the row, because it did not
contribute to the number being stored.
2026-07-31 15:05:51 +02:00
Marc-André Bombeck d92be0d0bd Lift the score breadth rule into one shared place
The composite needs three distinct domains and one physiological
pillar. Until now only the scorer could narrow the pillar set, so the
rule lived inside it. A settings write can narrow it too, and a second
copy of the rule would drift, so both layers now call the same
function over the same pillar-to-domain map.
2026-07-31 15:03:20 +02:00
Marc-André Bombeck 9982f87dc9 Merge work/bp-score-basis 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2d01b9846d Merge work/coach-read-locale 2026-07-31 11:48:21 +02:00
Marc-André Bombeck cf7bb13ee8 Explain a blood-pressure score in its own popover
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
2026-07-31 11:08:54 +02:00
Marc-André Bombeck 7233845b10 Close four ways around the locale guard
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.

A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
2026-07-31 11:08:06 +02:00
Marc-André Bombeck 0ce40b0950 Say why the blood-pressure pillar scores what it does
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.
2026-07-31 11:07:39 +02:00
Marc-André Bombeck 64a92fd392 Publish which blood-pressure axis set the pillar score
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
2026-07-31 11:04:20 +02:00
Marc-André Bombeck 271a49a822 Pin the Coach read strip's language, and keep the default out
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.

The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
2026-07-31 09:07:50 +02:00
Marc-André Bombeck 4ed6f7d1ce Write the Coach read strip's second line in the reader's language
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.

The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.

The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
2026-07-31 08:52:23 +02:00
Marc-André Bombeck 1c5191d1e3 Merge work/nap-separation 2026-07-31 08:21:55 +02:00
Marc-André Bombeck ee0375142c Merge work/cache-instance-proof 2026-07-31 08:21:18 +02:00
Marc-André Bombeck 8a316f5820 Record that the cache pin was checked against the entrypoint the image runs
The reading behind the pin was taken under `next start`. The container runs
`node .next/standalone/server.js`, and a fix that is green everywhere and
inert in production is the failure this project has paid for before, so the
same probe was re-run under the standalone entrypoint with static assets and
public copied the way the Dockerfile copies them.

No difference: three module instantiations either way, one shared registry
with the pin, separate registries without it, and a measurement write made
through a route handler reaches the page render's cache only when the pin is
there. The note says which entrypoint the claim rests on so the next person
does not have to wonder.
2026-07-31 08:20:34 +02:00
Marc-André Bombeck a99e98332c Pin the nap split against the night view, and the empty case against itself
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.

The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
2026-07-31 08:20:05 +02:00
Marc-André Bombeck 1f8c31d29e Keep a daytime nap out of the night in the sleep stage composition
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.
2026-07-31 08:13:58 +02:00
Marc-André Bombeck af70312306 Merge work/onboarding-honesty 2026-07-31 08:10:39 +02:00
Marc-André Bombeck 0fbaf6b2d3 Build the dashboard snapshot cache key from the one function that owns it
`dashboardSnapshotCacheKey` documents itself as the single source of truth
for the key, and the invalidators sweep by it, but the read spelled the same
string out by hand. The two matched, so nothing was broken today — edit one
side and the other stops matching silently, and the only symptom is a
dashboard that keeps painting the body from before the write.

The read now calls the function. The guard warms the cell through the real
read and then asserts the entry sits at the composed key exactly, so a
suffix change on either side is caught rather than forgiven by the prefix
sweep; two more cases cover a snapshot invalidation and a recorded dose.

The header on the shared read claimed both entry points ride the same cell.
That claim now holds, but only because the cache registry is pinned to the
global, so the header says so.
2026-07-31 08:07:04 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
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.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 96a3743009 Share one server cache between the home page and the API routes
A production build instantiates a module imported from both a Server
Component and a route handler once per bundler layer. The cache registry
sat in plain module state, so the home page's server render held its own
set of Maps and every API route held another. Recording a dose evicted
the route handlers' copy; the home page then rendered from a copy nothing
had touched and painted the dose as still due until the entry aged out on
its own, two or three minutes later. Because that render is dehydrated
into the client query cache, it also overwrote the fresh value the browser
had already fetched.

Measured on `next build && next start`, single process: the page render
and the snapshot route reported different cache instances, and after an
interactive write the route handlers' copy dropped to zero entries while
the page's copy stayed full.

The registry now lives on globalThis under a Symbol.for slot, which is the
only scope both layers share. db.ts guards its own global assignment with
a non-production check because it is only defending against dev reloads;
this one has to hold in production, so it is unconditional.
2026-07-31 08:03:21 +02:00
Marc-André Bombeck 8b3c0369d1 Merge work/vorsorge-due-day zone fix 2026-07-31 08:01:54 +02:00
Marc-André Bombeck d0a42908fe Hold the due phrase to the profile clock and to whole calendar days
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
2026-07-31 08:00:28 +02:00
Marc-André Bombeck 8e6880b04c Say when the onboarding profile step could not save a field
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.

It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
2026-07-31 07:58:45 +02:00
Marc-André Bombeck e1ca6d9da2 Work out the checkup due phrase on the same clock as the date beside it
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
2026-07-31 07:55:20 +02:00
Marc-André Bombeck f322736074 Merge work/vorsorge-due-day 2026-07-31 07:50:14 +02:00
Marc-André Bombeck 0471c7cb9b Merge work/dose-taken-lag 2026-07-31 07:48:39 +02:00
Marc-André Bombeck d432842a73 Stop the dashboard answering a refresh with a reading it fetched earlier
The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.

The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.

Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
2026-07-31 07:47:00 +02:00
Marc-André Bombeck 70cab3c885 Pin the checkup due line to calendar days
The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers.
2026-07-31 07:46:55 +02:00