Commit Graph
5272 Commits
Author SHA1 Message Date
Marc-André Bombeck 1bdc5ea4ee test(backup): bind MoodEntry to a named backup select
The mood read was an inline `include`, so nothing could compare it to the
model the way `measurement-backup-completeness.test.ts` compares the
measurement select. Name it — `MOOD_ENTRY_BACKUP_SELECT` and
`MOOD_ENTRY_TAG_LINK_SELECT` — and add the sibling guard that binds both
to `prisma/schema.prisma`.

The mapper's parameter is spelled `moodEntry` rather than `e` because the
guard proves a column reaches a payload by matching `: moodEntry.`, and `e`
is also the intake-event mapper's parameter one section above: a matcher on
`: e.` would match across both and could never fail.

The guard is red as it stands, and names the defect: `note`,
`noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt` and
`updatedAt` are selected and written to no payload. The fix follows.
2026-08-07 20:57:58 +02:00
Marc-André Bombeck 8a1585a391 Merge pull request #723 from MBombeck/fix/cycle-correctness
Cycle correctness: overdue verdicts, backfills, deletes that leave no torso
2026-08-07 19:58:39 +02:00
Marc-André Bombeck 3778e70ff5 Merge pull request #720 from MBombeck/fix/handover-p1-batch
Four data fixes: backfilled doses, impossible readings, stale today-claims, sleep units
2026-08-07 19:32:40 +02:00
Marc-André Bombeck 1f4b8f43dc Merge pull request #721 from MBombeck/feat/workout-hr-profile
Show the whole workout heart-rate profile
2026-08-07 19:01:51 +02:00
Marc-André Bombeck 7669868479 refactor(measurements): drop the entry-unit helpers nothing calls
`hasEntryUnitConversion` and `canonicalValueToEntry` shipped beside the
hours-to-minutes conversion for an editing surface that does not exist and has
no ticket. Neither has a caller, so both were only two more things to keep
true, and the dead-code gate is right to say so.

The module comment claimed the inverse was there so a future surface could not
land on a different answer. It says what is actually true now: one direction,
one place. The git history keeps the rest if that surface ever arrives.
2026-08-07 18:32:38 +02:00
Marc-André Bombeck 626ec203ac Merge branch 'main' into fix/cycle-correctness 2026-08-07 18:24:37 +02:00
Marc-André Bombeck 61501500b9 Merge branch 'main' into feat/workout-hr-profile 2026-08-07 18:24:35 +02:00
Marc-André Bombeck 384992bb15 Merge branch 'main' into fix/handover-p1-batch 2026-08-07 18:24:33 +02:00
Marc-André Bombeck 00e8d05235 Merge pull request #724 from MBombeck/fix/handover-ui-batch
Dialog overflow, a coach dot with nothing behind it, and six smaller surface fixes
2026-08-07 18:23:44 +02:00
Marc-André Bombeck 092edced9f test(e2e): open the mood sheet's full form before measuring it
The mood sheet opens on the five-face quick check and reveals the rest of
the form, timestamp row included, once a face is picked. The dialog-overflow
spec measured the quick check instead, found no date field, and failed on
its own readiness gate rather than on the layout — a guard that cannot reach
the state it is about proves nothing either way.

It now picks a face and waits for the annotate panel, which is the state the
report describes.
2026-08-07 17:14:41 +02:00
Marc-André Bombeck 381a07a134 Merge pull request #722 from MBombeck/fix/e2e-goto-race
Wait for the record switch to land before driving the next page
v1.37.0
2026-08-07 17:12:23 +02:00
Marc-André Bombeck 190941944c fix(insights): let the lab-comparison card age out, and give its delta the row's tier
Two things about the card that shows what changed since the last panel.

The comparison had no expiry. Someone whose newest panel is years old was
still reading "since your last panel" about a draw from another year — not
stale UI state but a stale sentence, and one the app kept repeating on the
overview every day.

The window is a year, applied to the data rather than to a per-user
dismissal. A year because routine panels are commonly drawn annually, so a
shorter window would hide a comparison that is genuinely the newest thing
somebody has for months out of every year, while past a year the wording
cannot be defended at all. Doing it on the data means nothing to
acknowledge, nothing to persist, no housekeeping asked of anyone, and one
answer for every client through the existing absent contract — the card
already un-mounts on it, and so does anything else reading that route.
Verified in both directions over the real endpoint.

The delta on the right was muted at the smaller size, the same typography
as the meta line under the analyte, so it optically hung off that lower
line and the row read as bottom-aligned even though it measures centred to
the pixel at every width. A delta is the number the row exists to report,
so it takes the content tier, and the cell states its own centring rather
than inheriting it.

The existing cases pinned their clock at the same time: fixed fixture days
against a relative window is a test that passes until a date in the future
and then fails for reasons unrelated to the code.
2026-08-07 16:52:24 +02:00
Marc-André Bombeck 41faf165ee refactor(anamnesis): give each lifestyle entry a proper action row
The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.

Each entry now ends in one right-aligned action row: the quiet destructive
first, the primary Save last, both at the mobile tap-target floor. The
remove control drops the field name from its visible label, which the
Label directly above already carries, and keeps the full phrase as its
accessible name and in the dialog it opens — so three of them side by side
stay distinguishable to a screen reader. Six languages.

Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
2026-08-07 16:45:17 +02:00
Marc-André Bombeck d7a95874c2 fix(coach): centre the composer text in its input pill
On a phone the placeholder sat noticeably low. The composer row was
bottom-aligned so the send button would stay beside the last line as the
textarea grows, but that also bottom-aligned a single-line textarea against
a 44 px touch target: 7 px of space above the text and 1 px below.

The row centres its textarea now and the controls keep their own bottom
alignment, so both properties hold. Measured empty, one line and four
lines, phone and desktop: the single-line gap goes 7/1 to 4/4 on a phone
and 2/1 to 2/2 on desktop, and the multi-line case is unchanged with the
send button still level with the last line.
2026-08-07 16:39:50 +02:00
Marc-André Bombeck c60aa6f9b1 refactor(settings): fold the profile-photo format line into its description
The accepted formats and the size limit were a caption of their own under
the upload buttons, so the card read as an explanation, a control, and then
a second explanation. They now finish the sentence that says what the card
does, which is where someone looks before choosing a file rather than
after. Same two strings, six languages unchanged.
2026-08-07 16:39:50 +02:00
Marc-André Bombeck 72cd21352c refactor(mental-wellbeing): shorten the intro and drop the mood-history link
The intro listed all four instruments with their subject in brackets and
then explained itself, which ran to two lines on a desktop header and five
or six on a phone — a paragraph to read before reaching the four cards that
say the same thing. It keeps its opening claim and the one caveat that
matters, and loses the enumeration the cards below already carry: one line
on desktop in all six languages, measured at 1280 and 1440.

The pointer to mood history at the foot of the page is gone. The link on
the mood page pointing here stays, because that direction earns its keep:
someone logging a mood may not know the screeners exist, while someone who
came here for a screener knows where their diary is.
2026-08-07 16:36:31 +02:00
Marc-André Bombeck 06dd7343c2 fix(settings): point the empty delivery-status card at a place, not a direction
The card told people to add a channel "below". A position is only true
while nothing above or below it moves, and the same card already reads as
the first thing in its group, so "below" says less than the group's own
name does. It now names Delivery channels, which stays true wherever the
card is rendered.

The card's title and its per-channel description were still English in
Spanish, French, Italian and Polish while the strings around them were
translated; they are translated now too. Six languages.
2026-08-07 16:31:10 +02:00
Marc-André Bombeck 82490870e5 fix(coach): make the launcher's dot mean a message you can read
The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.

Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.

The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.

Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.

No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.

Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.

The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.

Refs #715
2026-08-07 16:29:58 +02:00
Marc-André Bombeck eb5c8a91fb fix(ui): keep date/time pickers inside their dialog
The add and edit surfaces could be scrolled sideways and the time picker
sat past the right edge: measurements, mood, the medication intake editor
and the cycle log sheet all showed it, so the cause is in what they share.

Two mechanisms, both in shared primitives.

DateField and TimeField wrap a text input. A flex item's min-width:auto
resolves to a content-based minimum, and for a text input that is its
intrinsic size attribute — 20 characters, about 180px. Neither field could
render below ~230px whatever width the parent offered, so the date + time
row demanded ~470px inside a 400px dialog body and pushed the clock button
58px past the edge. min-w-0 on the field box and on the overlay input
releases that floor; the row now fits at every width from 360px up.

The scrolling body only declares overflow-y. CSS promotes the other axis
from visible to auto, so every deliberate outward bleed in the tree turned
the form into a pannable surface — SheetSection's trigger row was 8px wider
than its own container for no benefit (a focus ring already draws outside
the box), and Switch's hit-target pseudo reaches 13px past the track. The
bodies and the dialog surface now state overflow-x explicitly, the section
trigger no longer bleeds, and the dialog's grid column is pinned so an
over-wide child cannot drag the surface out with it.

Measured before and after on the four reported surfaces at 360 / 390 / 430
/ 768 / 1280 / 1440: the date/time row spilled 83px on every width from
640px up and none of it remains.

An e2e spec asserts the two harms directly — the surface is not a
horizontal scroll container, and no focusable control lies outside its
visible box — with a canary that proves the reachability check can fail.
2026-08-07 16:23:31 +02:00
Marc-André Bombeck 34be5b4554 test: wait for a record switch to land before navigating away from it
Entering or leaving a record is a server write followed by
window.location.assign, so the click that starts it returns long before
the document it produces exists. The switcher helper returned there, and
everything a test did next raced the app's own navigation.

Both ways of losing that race are silent. A goto issued into the gap is
cancelled when the assign commits, which Chromium reports as
net::ERR_ABORTED and which is what CI saw twice. Or the goto wins, and
the page it lands on belongs to the caller's own record, where a route
that is ungranted in somebody else's record is simply granted: /coach
opened in that gap issued all four of the protected reads the test
asserts never happen. The second is the worse outcome, because the
assertion was reading the wrong record and could as easily have passed
there.

Neither is visible in the DOM the click leaves behind, which is why
waiting on the banner cannot settle it: the document on its way out
already shows whatever the next assertion looks for, so the wait returns
at once and proves nothing. A marker on window can settle it, because a
document replacement is the one thing that destroys it. If the navigation
never happens the wait fails, which is what a switch that did not land
should do.

Both helpers use it now, so the retry that was added downstream of
leaving a record has nothing left to absorb; it stays, because the claim
there is that the record survives the round trip rather than that one
navigation attempt wins.

Reproduced before fixing, under CPU contention at CI's worker count:
three of six runs flaked, showing both faces. Six of six clean at the
same contention afterwards, then twenty consecutive runs of the file and
the whole sharing set, none flaky.
2026-08-07 16:15:54 +02:00
Marc-André Bombeck 85240e96b1 fix(cycle): route the new save signals through the shared affordances
Two repo-wide guards caught the additions rather than a reviewer, which
is what they are for.

The success toasts reached for sonner directly. The success affordance
belongs to the outcome module, which binds it to the success key and
nothing else, so a surface cannot raise a green toast for a write that
wrote nothing. They go through toastWrittenOutcome now.

The sheet's date header formatted a day key in the browser's zone. A
cycle day key is a calendar day, not an instant, so it is anchored at
noon UTC and rendered in UTC, the same anchor the domain's day
arithmetic uses. Otherwise a reader east or west of the browser zone
could see the sheet name a different day than the one it writes to.
2026-08-07 16:14:28 +02:00
Marc-André Bombeck 8e3834f38d fix(cycle): count the cycle day from the logged start, not the grid window
The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.

The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.

Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
2026-08-07 15:57:21 +02:00
Marc-André Bombeck 47d41a13f4 docs(cycle): say why the fertile window is seven days, not six
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.

The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.

The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
2026-08-07 15:53:55 +02:00
Marc-André Bombeck 30cf1da1d3 chore(cycle): add a read-only report for records the module stopped reading
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.

A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.

A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.

The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.

Nothing is written.
2026-08-07 15:52:59 +02:00
Marc-André Bombeck 3596720905 fix(cycle): let a period logged as flow count as a period
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.

A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.

The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.

An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.

- the period route's start arm and the flow inference now share one
  boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
  about what must NOT open a cycle
2026-08-07 15:50:23 +02:00
Marc-André Bombeck 029edfa358 Merge pull request #719 from MBombeck/fix/pdfjs-cve
Umove the PDF renderer past a script-execution flaw
2026-08-07 15:29:12 +02:00
Marc-André Bombeck bd6089734d fix(cycle): let the month grid read the month it is showing
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.

The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.

A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.

The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.

The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.

- calendar days carry isCycleStart, so the delete dialog can say that
  removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
  400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
  read, and comes back complete on a window over its month
- new strings in all six languages
2026-08-07 15:20:29 +02:00
Marc-André Bombeck ed274de0a0 fix(cycle): keep the cycle day when the phase band is withheld
The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.

The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.

The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.

- verdict round trip covers the still-learning day count and its overdue
  side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
  future-dated start is not a cycle anyone is in
2026-08-07 15:05:50 +02:00
Marc-André Bombeck 6db2f6f9ae fix(cycle): make removing a period start restore the neighbour it moved
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.

Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.

Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.

- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
  the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
  start, remove it either way, compare field by field — including that
  the forecast comes back
2026-08-07 15:03:10 +02:00
Marc-André Bombeck c5a471f7fa fix(cycle): keep counting an open cycle past the predicted period start
The engine ended the open cycle's phase window at the predicted next
start, so the day after a missed forecast carried no phase at all and
the verdict resolver read that as today falling outside every known
cycle window. An ordinary three-days-late period therefore reported
INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words
insufficient data, while the reminder cron was pushing a period-confirm
prompt for the same day. OVERDUE was only reachable when the engine
itself forecast a cycle longer than the grace window.

The open cycle now runs to the predicted start or through today,
whichever is later, and stops the moment the next start is logged. The
count is bounded on the far side where it already was: past the typical
length plus its grace window the verdict degrades to OVERDUE and says
how late the period is.

The extension pins the open cycle's ovulation anchor to the predicted
length. Left to the span, an unconfirmed ovulation is back-calculated
from the window's own length, so a window that grows with the delay
would walk the estimate and every phase boundary drawn from it.

- buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's
  today; the insights route and the coach snapshot pass it
- verdict round-trip covers the predicted start day, three days past it,
  past the grace window, and the case where a new start closes the run
2026-08-07 14:58:53 +02:00
Marc-André Bombeck cea330bf70 Say when a session has heart-rate figures but no profile behind them
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.

Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
2026-08-07 14:51:13 +02:00
Marc-André Bombeck 0c99c27bfc Keep the workout heart-rate axis on the readings it plots
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.

The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.

The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.

Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
2026-08-07 14:50:03 +02:00
Marc-André Bombeck 2f14379066 Carry the workout heart-rate curve to the end of the session
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.

Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.

The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
2026-08-07 14:41:24 +02:00
Marc-André Bombeck 315ca5e67c fix(deps): move the PDF renderer past a script-execution flaw
pdfjs-dist 6.1.200 and a transitive 5.6.205 both sit in the affected range
of a flaw that lets a crafted PDF run JavaScript inside the renderer. The
document vault feeds it every uploaded PDF, so pin the direct dependency to
6.2.108 and add a bounded override so the transitive copy lands there too.
The override stops short of pdf-parse's 5.4.296, which predates the flaw and
drives the local text-extraction path.

The runtime image ships every pdfjs-dist copy in the store, not just the one
that gets loaded, so an unused transitive copy still counted.

Hoisting in the image picked whichever copy the filesystem listed first, and
more than one copy reaches it, so the version named in package.json was not
reliably the one the renderer resolved. Carry the resolved version forward
from the build stage, select it by name, and fail the build when it is
missing rather than leaving another copy in its place.
2026-08-07 14:35:23 +02:00
Marc-André Bombeck d97e3c0976 fix(measurements): store a hand-entered night in the unit the field asks for
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.

Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.

Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.

The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.

The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.

`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
2026-08-07 14:30:10 +02:00
Marc-André Bombeck dfd9b4679b fix(insights): stop making claims about today out of older readings
The hero line said a pulse was well above its 30-day average today. Nothing
had measured a pulse for five days. The day-signals card flagged the same
reading, and the baseline-drift card said the value was above the usual range,
present tense, on a day that held no reading at all.

One class, and it sits in the data preparation rather than in any of the
sentences. Each surface was handed "the freshest stored value" with no notion
of when it was taken, so each of them assumed now. The age was reachable — the
aggregates carry `newestDaysAgo` inside their coverage object — but it read as
a statistics footnote rather than as the fact that governs the tense of every
sentence about the metric, and none of the three consulted it.

A reading is material for a claim about today when it comes from today or from
yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10
is the same reading it was half an hour earlier. Two is not, because by then a
whole day has passed with nothing in it and the sentence would be describing a
gap.

The rule now lives beside the data. The vital deviations carry the age of the
reading they were computed from, and the two deterministic consumers apply it:
the day-signals flag counts only current vitals toward its count, and the
baseline-drift card surfaces only current deviations. Stale vitals stay in the
list with their age, so the anatomy view still shows them and nothing silently
disappears; the dated level shifts were never today claims and are untouched.

For the narrated surfaces the same fact is stamped onto every metric block of
the Coach snapshot before anything reads it, so the age travels with the
numbers into the hero, the briefing, the Coach reply and the retrieval tools
instead of each of them guessing. A block narrated off a stale series also
lands in the wide event, which is where it should have been visible all along.
2026-08-07 14:24:13 +02:00
Marc-André Bombeck 4563bce5b3 fix(insights): keep impossible readings out of the personal band
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.

The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.

Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.

The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.

Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
2026-08-07 14:16:17 +02:00
Marc-André Bombeck 4e9e8649ec fix(medications): record the slot a backfilled dose names
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.

The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.

A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.

The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
2026-08-07 14:03:09 +02:00
Marc-André Bombeck d5f176ac4d Merge pull request #718 from MBombeck/release/v1.37.0
v1.37.0 — share part of a record, or keep one for somebody with no login
2026-08-07 13:45:19 +02:00
Marc-André Bombeck f49defbc34 fix(documents): do not check a close that deliberately kept its selection
Maximizing the Coach drawer hands the conversation to /coach and carries
?doc= with it, so browser-Back can rebuild the sheet. That is the one
close-shaped transition whose whole point is to LEAVE the selection where
it is, and the close path has always returned early for it.

The dropped-traversal check added beside that close did not know. It saw
a closed sheet and a selection still in the address bar, which is exactly
the state it exists to repair, and it repaired away the return URL a
quarter of a second after the close had deliberately preserved it.
Whether it won that race was a question about how fast the machine
navigated, so it surfaced as one flaky run on the narrow viewport rather
than as a broken feature.

Both sides read one named predicate now, and the check is skipped when
the close kept the selection on purpose. Proved by driving the grace
window to zero, which is the worst case the timer can have: before, the
maximize never even reached /coach, three attempts out of three; after,
it passes there and the whole documents family passes with it. Twenty
consecutive runs of that family on the narrow viewport are clean.
2026-08-07 13:15:23 +02:00
Marc-André Bombeck 9849a369a1 docs(changelog): record the stuck-hold fix
Half a minute on a spinner with two tabs open is a user-visible defect in
a release whose subject is exactly that surface.
2026-08-07 12:13:15 +02:00
Marc-André Bombeck 6ec78d2ec4 test: wait for the cached entry the disk-layer control asserts
The service worker writes its data-cache entry after the response has
been handed to the page, so a shell that is ready is not evidence that
the write has landed. Reading the cache once therefore sampled null on a
loaded machine, and the positive control failed saying there was no entry
about a cache that was about to have one.

Polled on the entry itself. It waits for the signal rather than for a
number of milliseconds, and it still fails if the entry never appears,
which is the whole reason the control is there.
2026-08-07 12:12:51 +02:00
Marc-André Bombeck 65531c359a fix(sharing): stop a switch mistaking its own reconcile for a foreign one
The scope mirror is watched so a switch this browser did not begin still
holds every shell until /me reconciles it: another tab on an older
bundle, the referential action behind an account deletion, an operator.
It recognised its own by comparing the mirrored scope against the scope
it was switching to, and that is not a true test. fetchMe mirrors
whatever record the server currently reports and it runs DURING a switch,
both when postSwitchWithReconcile reconciles a lost compare-and-set and
when a second tab reloads beside the initiator. At that moment the server
still reports the record being left, so the write did not match and was
read as somebody else's switch.

What followed was silent. A foreign write publishes a new transition id,
and every terminal call opens by checking the id it was handed, so the
commit that followed was dropped without a trace and the expected scope
was left naming a record no /me would ever confirm. Nothing could release
the hold, and both tabs sat behind the hydration gate until the stored
transition aged out thirty seconds later.

That is the intermittent browser failure that has been carried through
three rounds as a canary and never explained: a peer reload racing a
switch, a gate that never lifts, and a run forty-six seconds long made of
a fifteen-second wait and a thirty-second one. On the selection that
provokes it, eight runs failed three times before and twenty failed none
after, and the runs that used to take fifty-seven seconds now take
twenty-five.

A write is foreign only when nothing of this browser's own is in flight.
Ignoring it while held costs nothing, because a held browser is already
in the state that branch would install. The rule is a named function so
it can be checked in a suite that has no DOM, and the property the guard
exists for is pinned beside it: a response that disagrees with a
committed scope is stale and still must not release.
2026-08-07 12:12:39 +02:00
Marc-André Bombeck ad094cba4d docs(changelog): record the document-close fix
It is a user-visible defect in a release that ships the surface it lives
on, so it belongs in the entry rather than only in the commit that fixed
it.
2026-08-07 10:27:22 +02:00
Marc-André Bombeck 53ffbfdec4 test: give the two sharing probes instruments that cannot fail by themselves
Both failed on CI and neither failure was about the product.

The loading-state scan held /api/auth/me open with a fixed sleep in the
route handler. Three things follow from that and all three are wrong: the
hold ends 1500 ms after the first request whatever the axe scan is doing,
so the state being scanned is not the state the test names; every refetch
queues another sleeping handler, so how many are live is a property of
the machine; and a handler still sleeping when its request is cancelled
answers route.continue with "Route is already handled", which is what the
runner reported. It now holds on an explicit gate released at a point the
test chooses, counts what it held so a page that was merely slow cannot
pass for a page it held, and asserts the gate goes away on release, which
is the exit control the entry assertion never had.

The labs doorway proved that no OCR probe fires by waiting for that
request with a 500 ms timeout and expecting the rejection. An instrument
whose pass condition is its own failure has to be armed early and awaited
late, and in between it is an unhandled rejection that lands on whichever
test is finishing. Its window was wrong too: the timer started before the
switch was clicked, so on a slow runner it could expire before the labs
page existed. A request listener replaces it, covering everything from
before the click to after the record read landed, with the domain's own
read asserted in the same set so an absence cannot be a statement about a
matcher that stopped matching.
2026-08-07 10:26:41 +02:00
Marc-André Bombeck a5b6508961 fix(documents): check that closing a document consumed its history entry
Closing a card-opened document asks the browser to go back, so the entry
the selection pushed is consumed and the reader's next Back reaches the
page before it. A traversal is a request, not a guarantee: one asked for
while another is still settling can be dropped, and nothing raises when
it is. The sheet closes, React stays consistent, and the address bar goes
on naming a document nobody has open, so a reload or a shared link
re-opens the sheet that was just closed.

Pressing Escape straight after the browser's Forward button is exactly
that shape, and it reproduces: ten repeats across both viewports failed
two of twenty with the URL still carrying the id, and none of twenty once
the close verifies itself. The check is guarded by the document id, so a
traversal that landed on a different selection is left alone, and it is
cancelled the moment the sheet re-opens.

The browser test that found it could not say so. It asserted the sheet
was closed through a locator matched on the document TITLE, and the sheet
falls back to an untitled label while its detail read is pending, so the
assertion also passed on an open sheet showing a skeleton, resolved
immediately instead of waiting, and pushed the failure into a URL poll
ten seconds later. It now asserts the dialog role, which is what the
neighbouring deep-link test already does.
2026-08-07 10:26:25 +02:00
Marc-André Bombeck e2eea55b6e chore: drop two exports nothing reaches
The dead-code gate has been red on this branch since the sharing refusal
contracts landed. Neither name is new and neither was noticed, because
knip is not in the local gate.

consentRequiredResponse lost its last caller when every delegable route
started sharing one 403 description through recordRefusal(); the AI
consent sentence it wrapped is still used and stays, with a note at the
constant saying why there is no response object beside it any more.
mutateThenRestore was written with the sharing security fixtures and has
never been called.

No ignore entries: an export with no consumer is the thing the gate is
for.
2026-08-07 10:25:52 +02:00
Marc-André Bombeck c3c058074b chore(release): v1.37.0 — share part of a record, or keep one for somebody with no login 2026-08-07 08:25:17 +02:00
Marc-André Bombeck 2980cca200 docs: bring the sharing copy up to what the release ships
The operator page still said every grant is read-only, one release after
the write level shipped, and the table of what a grant cannot reach
predates both the manage level and managed profiles.

Rewritten against the frozen behaviour: the eight sections a grant can be
narrowed to and what each opens, the three levels and what each adds, the
step-up that is unconditional on a managed profile and conditional on an
ordinary invitation, and what a guardian may keep for a record that has no
login of its own. The AI row is corrected rather than dropped: a manage
grant reads insights that already exist, and no level causes an outbound
provider call.

Every claim on that page was read out of the code before it was written
down. The one about backups was cut instead, because it was not.
2026-08-07 08:20:08 +02:00
Marc-André Bombeck 6bb0360695 docs(api): publish the sharing request bodies as named schemas
Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.

Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.

The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
2026-08-07 08:10:48 +02:00