Commit Graph
4882 Commits
Author SHA1 Message Date
Marc-André Bombeck 03d2a712d0 Move the contract tests to the end that now writes
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.

Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.

The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
2026-08-02 02:00:08 +02:00
Marc-André Bombeck c9ee84e5e4 chore(release): v1.35.5 — a read stops deciding what gets written 2026-08-02 01:30:12 +02:00
Marc-André Bombeck b40e9bc40f Merge work/side-effecting-gets 2026-08-02 01:29:46 +02:00
Marc-André Bombeck 1e6f72b1c5 Prove the achievements read writes nothing against a real pending unlock
The assertion in the module-gate suite could not fail: that fixture's mood
badges carry no completion date, so the result it drove had no pending
unlock and "nothing was written" was true either way. Restoring the INSERT
left it green.

The case moves to its own file with the builder faked so the result always
carries a pending unlock. Undoing the fix now turns it red. The
disabled-module invariant the old case reached for lives with the write, in
the sweep's suite.
2026-08-02 01:27:42 +02:00
Marc-André Bombeck 6ac3a61829 Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
2026-08-02 01:21:03 +02:00
Marc-André Bombeck 817886e145 Merge pull request #678 from MBombeck/release/v1.35.4
release/v1.35.4
v1.35.4
2026-08-02 00:57:18 +02:00
Marc-André Bombeck 2618a7ae71 chore(release): v1.35.4 — the checks tell the truth about the calendar and about themselves 2026-08-02 00:38:01 +02:00
Marc-André Bombeck c682145627 Merge work/score-version-fixtures 2026-08-02 00:37:11 +02:00
Marc-André Bombeck 7a6335a2c7 Merge work/session-surface-guard 2026-08-02 00:37:10 +02:00
Marc-André Bombeck 3b9504a441 Merge work/today-day-arithmetic 2026-08-02 00:37:10 +02:00
Marc-André Bombeck eb641dcd7b Merge pull request #677 from MBombeck/fix/vorsorge-test-timezone
Anchor the checkup due fixtures to the zone the component reads
2026-08-02 00:36:52 +02:00
Marc-André Bombeck 9fb0f8b798 Anchor the checkup due fixtures to the zone the component reads
The two calendar-day cases built their due instant with setHours on the
process clock, which vitest pins to UTC, while the component floors the due
date in the profile zone. Those two disagree about the date for the two hours
before UTC midnight, so both cases went red every night in that window and
green again by morning.

Deriving the date parts from the display zone removes the disagreement rather
than hoping the suite never runs at night. Noon is chosen deliberately: at any
offset the zone uses, 12:00 UTC lands on the same calendar date there, so the
instant cannot slide either way.

Verified inside the failing window: red before, green after, and putting
setHours back turns it red again.
2026-08-02 00:19:59 +02:00
Marc-André Bombeck af35346687 Derive score-version fixtures from the SCORE_VERSION constant
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.

The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
2026-08-02 00:15:29 +02:00
Marc-André Bombeck da85e64cfa Catch the Bearer resolver the guard's matcher walked past
The Bearer guard demanded the literal `apiToken.findUnique(`.
`lib/idempotency.ts` writes the same call fluent-style, with a newline
between `prisma.apiToken` and `.findUnique({`, so the matcher never saw
it. It has been resolving a raw Bearer value to a user id since the
idempotency work landed, outside the frozen set, while the guard stayed
green on five files.

Tolerate whitespace around the member access and name the file with what
it does: it decides which user's idempotency cell a replay lands in, and
authorises nothing, because the handler still runs its own requireAuth.

Add the non-zero count assertion the other guards now carry. Narrowing
the regex back to its old form turns this test red, which is the proof
the broadened matcher is what finds the file. The mint-site matcher in
T3 was checked for the same defect and has no hidden members.
2026-08-01 22:45:04 +02:00
Marc-André Bombeck a08631e0a1 Freeze the set of surfaces that resolve a caller by any mechanism
The Bearer guard freezes one mechanism: the files that turn an
Authorization header into a user. It reads like coverage of "who can
resolve a caller" and it is not. Two other families do the same job by
other means and no guard watches either.

Fifteen files under src/app read the session cookie directly instead of
going through requireAuth: eight API routes and seven pages. Nine more
authenticate on a token carried in the URL, including the clinician
share tree, which serves one person's whole health record to an
anonymous caller holding a path segment. A sweep that greps for
getSession sees none of the second family.

Freeze both as literal lists with a reason per entry, matched by five
independent legs: the session-deriving helpers, a direct read of the
session cookie name, a dynamic route segment carrying a credential,
share-token resolution, and a query-string credential. Legs overlap, so
each asserts its own membership and its own non-zero count. A leg whose
matcher stops matching then fails instead of letting the union carry it.
2026-08-01 22:39:50 +02:00
Marc-André Bombeck 7df25a6d92 Merge pull request #676 from MBombeck/fix/settings-overscroll
Give the scroll container its own containing block
v1.35.3
2026-08-01 21:42:27 +02:00
Marc-André Bombeck 6bc5aff84a Record the pre-hydration scroll surface in the release notes 2026-08-01 21:25:52 +02:00
Marc-André Bombeck 1b7dff411a Give the scroll container its own containing block
`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.

The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.

Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
2026-08-01 21:24:24 +02:00
Marc-André Bombeck 6d4eefe26b Merge pull request #675 from MBombeck/release/v1.35.3
release/v1.35.3
2026-08-01 20:20:27 +02:00
Marc-André Bombeck 67d4f87720 Hold the checkup overdue flag for the whole day it is due
The MCP due-list answered "is this checkup overdue" by comparing the two
instants, so a checkup booked for nine in the morning read overdue from ten
past. The screens read the same reminder as "today" until midnight. One
reminder, two verdicts, and the model reading the tool result repeated the
one nobody could see.

Same counting as the screens and the briefing payload now, on the account's
own clock rather than the server's. A checkup stays due for the whole of the
date it belongs to, and is overdue from the date after.
2026-08-01 20:09:37 +02:00
Marc-André Bombeck 49ac6679c8 Count preventive-care days on the person's calendar, not in hours
The briefing payload states how far off a checkup is as a bare number the
model narrates back: "due in N days", "overdue by N days". It got that
number by dividing the hour gap by twenty-four, which answers a different
question than the one the sentence asks. A checkup booked for nine this
morning came out "overdue by 0 days" by the evening. One due Sunday at
01:00, read late on Friday, came out one day off — Saturday, on a calendar
where it is two. A clock-change day is twenty-three or twenty-five hours
long and rounded whichever way its length pushed it.

The checkup screens had exactly this and it was fixed there, so the counting
already exists. Lift it out as `calendarDaysUntil` and have both the phrase
and the number come off it, rather than leaving the payload with a second
body that can drift again. It counts by differencing two integer day indices
read on the person's profile clock, so the answer is exact rather than
rounded, and `extractFeatures` resolves that zone and hands it down — the
number now agrees with the screen the person is looking at.

Today's checkup moves off the overdue list, where the hour gap had put it as
soon as its appointment time passed. The hour it was booked for may be
behind, but the day it belongs to is not.

The horizon that bounds the read stays an hour cutoff on purpose. It is a
query bound, not a statement to anyone about which day something falls on.
2026-08-01 20:04:10 +02:00
Marc-André Bombeck ffe5545032 chore(release): v1.35.3 — an ECG arrives on its own, and only once
Apple Watch ECG recordings reach the record straight from the app instead of
only through an export archive. One recording per request, gated by the same
switches that already govern reading them.

A recording is now identified by what it is rather than by what a source calls
it, so the same strip arriving through both doors converges on one row. The
migration that adds the constraint resolves the duplicates already stored: it
keeps the row holding the waveform over one holding only a verdict, and moves a
removed row's event link onto the row that is kept. The losing rows are deleted.
2026-08-01 19:54:08 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 66286f085a Merge pull request #674 from MBombeck/release/v1.35.2
release/v1.35.2
v1.35.2
2026-08-01 19:49:28 +02:00
Marc-André Bombeck 42e16a6e12 Stop the plaintext-waveform check passing on a coincidence
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
2026-08-01 19:35:16 +02:00
Marc-André Bombeck d3d32ff8e0 Reset the today-just-in fixture through the app, not behind its back
The mobile project read the day as final on a table with no sleep in it.
The rows were gone; what was left was the dashboard snapshot the desktop
run built from them. It carries the sleep-freshness the digest reads, it
lives in the analytics SWR bucket at sixty seconds fresh and an hour of
stale-serve, and a DELETE straight into Postgres does not touch it. So
the second project asked whether last night's sleep was in, and a cached
answer from the first project said yes.

The purge now goes through the bulk-delete route. That tombstones the
rows the way the management list does, recomputes the rollup buckets the
snapshot reads through, and hard-evicts the bucket, so the next read
rebuilds from the table rather than from the run before it. The
delete-by-external-id route names the rows the way this spec does, but it
invalidates without evicting, and a marked-stale cell is served while it
revalidates - the next read would still have been the stale one. The ids
come from SQL because the measurements list collapses overlapping sources
to one row per day and can hide a row that still counts.

The purge stays where it is rather than moving into the cleanup block:
the eviction only happens when the delete removes something, so tidying
up at the end of each run would leave the next one with nothing to delete
and the same stale snapshot.

The starting state is asserted, not assumed, and it stays that way.
2026-08-01 19:33:27 +02:00
Marc-André Bombeck 8f90fd3174 Prove the ECG ingest, the dedupe and the migration on dirty data
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.

The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.

The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
2026-08-01 19:26:47 +02:00
Marc-André Bombeck 0e301b580c Let an Apple Watch ECG arrive without a 1.5 GB export archive
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.

The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.

Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.

The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.

If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.

The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
2026-08-01 19:19:01 +02:00
Marc-André Bombeck 008a6d46a4 docs(changelog): drop third-person narration and review-process detail
The changelog is meant to read as my own notes, but a stack of entries
narrated me in the third person ("the maintainer's complaints", "per
maintainer directive", "26 of the 27 maintainer findings landed") and
carried internal review choreography no reader can use: numbered review
rounds, finding counts, item codes, QA severity labels, a local path to
a design handoff.

Every one of those now states the change instead of who asked for it.
Where the only content was the attribution, the line is shorter or gone.
No entry's technical claim changes. "maintainer" stays wherever it means
the operator role of a self-hosted instance.
2026-08-01 18:38:13 +02:00
Marc-André Bombeck ad1554a36f Make the round-trip case actually round-trip
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.

It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
2026-08-01 17:56:20 +02:00
Marc-André Bombeck 96c10f07e6 Give the today-just-in spec an account that earns a hero
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.

Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.

So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.

While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.

The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
2026-08-01 13:21:04 +02:00
Marc-André Bombeck 99ceb236de chore(release): v1.35.2 — one answer, published
The cycle ring stops working out its own verdict and reads the server's. The
constant that made that possible is deleted rather than deprecated, because a
constant left in client code is a constant that gets copied again, which is how
the other client's ring froze in the first place.

The score settings page takes the shape the other settings cards have, loses a
pillar that could never score, and turns green where the app's other two scores
already did. A saved selection now comes back as it was sent.

Five gaps in the published specification are closed, including a route that
shipped this morning and was never described at all.
2026-08-01 12:16:27 +02:00
Marc-André Bombeck 368eb44439 Point the score-config contract test at the pillar list that survived
Two branches met here. One retired the fitness pillar and renamed the test's
pillar list accordingly; the other, written against the eight-pillar world,
referred to it by its old name and its old count. The merge kept the new
declaration and the old references, so nothing compiled.

The name carried the count, which is why the collision was visible at all.
There are seven now.
2026-08-01 11:59:29 +02:00
Marc-André Bombeck a830e8a9d3 Merge work/contract-touchups score-config registration 2026-08-01 11:58:17 +02:00
Marc-André Bombeck 7ddcee0445 Publish the health-score configuration route
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.

Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.

The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.

The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.

The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
2026-08-01 11:57:31 +02:00
Marc-André Bombeck f6edff9b82 Merge work/contract-touchups 2026-08-01 11:50:33 +02:00
Marc-André Bombeck 6a69bad05e Publish the passkey list and the base-token 422
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.

`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.

/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
2026-08-01 11:49:40 +02:00
Marc-André Bombeck 43c591fe19 Correct three contract claims the published spec got wrong
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.

The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.

The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
2026-08-01 11:42:07 +02:00
Marc-André Bombeck f6bd8be6d5 Merge work/fhir-retire 2026-08-01 11:38:55 +02:00
Marc-André Bombeck 5767e0330b Retire allowFhirApi and resourceTypes off the share-link response
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.

Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
2026-08-01 11:38:16 +02:00
Marc-André Bombeck 4f79b9d49a Merge work/cycle-verdict 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 4d200ebd43 Merge work/score-settings-polish 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 9bcd5dfaf5 Turn the health score green at 70
The app carries three scores and meant two things by a green dot.
Readiness and the sleep score both call 70 green; the health score alone
wanted 75. One app cannot do that, so the outlier moved. The yellow floor
stays at 50: raising the bar for a reward and lowering the bar for a
warning are different decisions, and only the first one was on the table.

The band is still not a function of the number. The composite takes the
worse of the mean's band and the worst counted pillar's, so a composite
carrying a red pillar stays red however high the mean sits - the stored
band on a recorded day exists for exactly that reason. Only the mean-side
thresholds moved.

The method version moves with it. A day already recorded keeps the band
it was actually shown under, and the notice key carries the version, so
each account is told once that the rules moved. The week-over-week
comparison is left alone on purpose: the number itself does not change,
only the colour it is drawn in, so a subtraction across the change still
compares two numbers worked out the same way.
2026-08-01 11:09:38 +02:00
Marc-André Bombeck 4e934a191d Drop the just-in line from the Today hero
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.
2026-08-01 11:09:37 +02:00
Marc-André Bombeck ee63b09e59 Rebuild the score settings card the way the other settings cards are built
Save moves out of the header and into its own row at the bottom right,
where every other settings card that saves a form puts it. The body
starts on the title's reading edge instead of the card edge, so the text
lines up with the cards above and below it.

The rules between the rows are gone; the gap between groups does the
grouping. Every row said 'Being recorded - Shown in the app' under the
pillar name, which is the modules screen's business and was the same two
words on every line - the sentence at the top of the card already says
it once. The paragraph explaining that heart, glucose and cholesterol
are three pillars in one area is gone too.

The area headings read in the content colour now rather than the muted
one, so they stop looking switched off beside the rows they head.
2026-08-01 10:53:44 +02:00
Marc-André Bombeck fb1bf710e6 Merge pull request #673 from MBombeck/release/v1.35.1
release/v1.35.1
v1.35.1
2026-08-01 10:52:52 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
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.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck 043d2ba143 Give the mobile cycle smoke test a verdict to render
The spec hand-builds a calendar response, and the wheel now reads the
resolved verdict from it. Without the field the ring paints empty, so the
fixture derives the verdict from the same phase plan it derives the day
grid from and the two cannot drift apart.
2026-08-01 10:48:51 +02:00
Marc-André Bombeck c65f122017 Prove the verdict reaches a client and that nothing recomputes it
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.

The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.

The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
2026-08-01 10:45:24 +02:00
Marc-André Bombeck dbecbbcbdb Publish the resolved cycle verdict instead of letting each client work it out
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.

GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.

The overdue caption now names the number of days instead of only saying
later than usual.
2026-08-01 10:37:18 +02:00