Commit Graph
5416 Commits
Author SHA1 Message Date
Marc-André Bombeck f28c6df861 Merge branch 'main' into feat/encounter-phase-2 2026-08-08 16:11:50 +02:00
Marc-André Bombeck 57cc52dfe2 Merge pull request #744 from MBombeck/feat/mood-prognosis
The mood module can say what a day would have looked like on your own past patterns
2026-08-08 16:09:59 +02:00
Marc-André Bombeck 5d6bc73cdc test(encounters): guard the batched link redaction on the visit list
The per-family withholding of a link's label and date from a scoped delegate
was proven on the detail read and not on the list read. The two resolve links
through different functions — the list through the batched loadEncounterLinksForMany,
added when links moved onto the list — and a batched read that dropped the
redaction would leak a lab analyte and its date to a delegate the single read
withholds them from, with no assertion on the detail path to notice.

Three legs, mirroring the detail coverage against the list route: an
entire-record delegate sees every name, a profile-only delegate sees the shape
of all three links and the name of none, and a profile-plus-labs delegate sees
the lab name and neither of the other two. The entire-record leg is the control
that stops the redaction assertion passing against a read that redacts
unconditionally.
2026-08-08 15:54:19 +02:00
Marc-André Bombeck 0bf3f5c8d9 Merge remote-tracking branch 'origin/main' into feat/mood-prognosis 2026-08-08 15:41:13 +02:00
Marc-André Bombeck 75c43c74bc docs(mood): state the validation's real leak boundary, drop the unused weekday floor, correct two comments
Four honesty fixes with no behaviour change to the math.

The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.

The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.

Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.

And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.

The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
2026-08-08 15:30:38 +02:00
Marc-André Bombeck d58edc5deb fix(mood): floor the fit on complete rows, not only on rated days
The ladder gate counts days carrying a rating. But a day missing one of
the fit's selected features is dropped rather than imputed, so a
sparse-context account can clear thirty rated days and be fitted on far
fewer complete rows — a forecast "built from twenty days" past a floor
meant to say thirty. n beside the value and a wide band on a small fit
keep it from being dishonest, but the floor should still hold.

The number of complete rows the fit actually used now has to clear the
same threshold as the rated-day count; below it the account gets the
absence shape, and the read path then reports "not enough of a pattern
yet" rather than a value resting on a handful of days.

Watched red with a fixture of forty rated days carrying the driving
feature on only the twenty-five most recent — enough to screen the
feature in and to score the recent days, so the refusal is about the
complete-row count and nothing else. Before the floor it wrote n=25.
2026-08-08 15:30:20 +02:00
Marc-André Bombeck 9a47b4db49 fix(mood): refuse a forecast below the data floor on the read path, not only in the job
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.

The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.

The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
2026-08-08 15:30:00 +02:00
Marc-André Bombeck dd04931c9d chore(query-keys): restore the visit invalidation bundle with its consumers
The bundle landed a release before the surfaces that read it and was removed
on trunk as dead. Both mutation hooks in this branch use it, and the merge took
trunk's deletion, so it comes back here together with the writes that need it.
The comment now names its consumers, so the next dead-code sweep can see them
without reading two files.
2026-08-08 15:22:13 +02:00
Marc-André Bombeck bfdc2ae025 Merge remote-tracking branch 'origin/main' into feat/encounter-phase-2 2026-08-08 15:08:17 +02:00
Marc-André Bombeck 2d69842994 fix(encounters): the visit list carries its links
Two defects behind one omission. The card counts what a visit produced, so a
list that dropped the links made every card read zero for a visit with three
documents. And the edit sheet seeds its pickers from the row it was handed: an
edit opened from a linkless list row would save a replace-set of three empty
arrays and silently unfile everything the visit had collected.

The links now ride the list, resolved in three grouped queries for the whole
page through the batched arm of the same service the detail uses, with the same
per-family redaction from the same predicate. The single-visit read hook had no
caller and is gone with it — the list is where the surfaces read from.

Both defects get a test that would have caught them: the card counts assert
against a populated links object, the draft asserts the arrays survive a round
trip through draftToBody, and the integration file asserts the list response
itself carries every family.
2026-08-08 15:06:10 +02:00
Marc-André Bombeck bdfea7dd85 test(encounters): the Phase 2 surfaces against real Postgres, and end to end
Three properties here cannot be answered by a mocked client, and each has a
shape that would pass a weaker assertion while being wrong:

- filing a visit against a due checkup twice satisfies it ONCE. Phrased as
  "the second call returned 200" it proves nothing, so the assertion is that
  lastSatisfiedAt did not move — and the instant is captured once, because two
  calls milliseconds apart are two different filings and the forward-only guard
  correctly advances on the later one.
- a lab commit writes one link PER RESULT ROW. A test that asserted "there is
  a link" would pass on one link for a three-marker panel.
- the visit list's condition filter runs in SQL. With one visit in the account
  a filter and no filter return the same list, so there are always two.

The bulk arm gets per-entry tolerance over a hundred documents, one unknown id
reported as one failed row with the rest filed, and a foreign visit refusing
the whole batch before anything is written.

The e2e spec proves the incidental-linking promise once, addressing stable
data-slot attributes rather than viewport text: a visit saves with a date and
nothing else, and a document dated inside the window offers that visit,
pre-selected because there is exactly one, and files itself against it.
2026-08-08 14:51:56 +02:00
Marc-André Bombeck 4d2f706dad feat(doctor-report): a report can mention a visit, and know why it exists
The VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.

The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.

No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.

Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.

The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
2026-08-08 14:38:26 +02:00
Marc-André Bombeck c47122ccfe feat(encounters): the lab moment, the condition side, and closing a checkup
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.

The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.

A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.

An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.

A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.

OpenAPI regenerated in the same commit.
2026-08-08 14:08:09 +02:00
Marc-André Bombeck 01f94df60b Merge pull request #745 from MBombeck/fix/encounter-orphan-exports
Drop the two visit exports nothing consumes yet
2026-08-08 14:03:50 +02:00
Marc-André Bombeck 7981c34cc7 Drop the two visit exports nothing consumes yet
The timezone resolver and the dependent-keys bundle shipped ahead of
their consumers, and the dead-code gate rightly went red on the trunk.
The surfaces that need them arrive with the next visit stage and bring
them back together with their callers.
2026-08-08 13:48:14 +02:00
Marc-André Bombeck be9c79974f feat(documents): a document can say which visit it belongs to
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.

Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.

The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.

The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.

OpenAPI regenerated in the same commit.
2026-08-08 13:41:42 +02:00
Marc-André Bombeck ed1438230c feat(encounters): visits and the address book become reachable
/checkups gains a second section rather than a nav entry. The page already
owns the right sentence — when do I have to do what, where — and is
deliberately not module gated, which is the same reason a visit carries no
module key of its own: what a visit may point AT follows the modules that own
those things, so the gate sits on the three link pickers and blanks the block
rather than rendering an empty one.

A visit saves with a date and nothing else. No required practitioner, no
required kind, no required link, and the only thing that can disable Save is a
date that will not parse. The status is derived from the date on a new visit
and only becomes a field on an existing one, because somebody booking an
appointment should not also have to tell the app that it is in the future.

The address book is record content, not configuration, so it sits under
/checkups rather than in Settings — and it inherits that page's route-family
classification with it. Deleting an entry says in one sentence that the old
visits stay, because a person deleting a doctor has every reason to fear
otherwise.

Two defects closed on the way:

- /checkups carried sharedRecord: true and appeared in neither route family,
  so a scoped grant could not present it at all. It was invisible while a
  whole-record grant admitted every path; the visits section makes it load
  bearing, and the page is now listed under both domains whose content it
  shows. Deduplicated, or a grant holding both would offer the page twice.
- the address-book search read the name only, so looking a practice up by the
  name on the door returned nothing and read as "not in the book".

The practitioner search description in the OpenAPI module moved with it.
2026-08-08 13:21:43 +02:00
Marc-André Bombeck 8d48e3e262 Merge remote-tracking branch 'origin/main' into feat/mood-prognosis 2026-08-08 13:00:02 +02:00
Marc-André Bombeck 467e83d639 feat(encounters): one suggestion rule for every filing moment
A document arriving in the vault and a lab panel being committed both ask the
same question — does this belong to a visit? — and the answer has to be the
same question asked once. The rule lives in one module with one exported
window constant: the caller's live DONE or PLANNED visits within seven days of
an anchor date, resolved into a verdict rather than a list.

One candidate pre-selects, visibly, with one undo. Two or more offer a picker
with nothing pre-selected: pre-selecting the first of two is what teaches a
person to stop reading suggestions, and the return type has no field on that
arm a caller could mistake for a choice. Zero offers nothing.

Cancelled visits and no-shows are not candidates. Neither happened, so neither
produced the letter or the panel being filed, and admitting them would widen
the set with rows that are wrong most of the time — which pushes the honest
single match into the picker branch.

The verdict is served over HTTP because all three moments live in the browser.
Handing the client a windowed list and letting it decide would put the rule in
two places, and the copy in the browser is the one nobody re-reads.

- suggest-window.ts with the window as an exported constant, not a literal
- GET /api/encounters/suggest, profile domain, reviewed into the frozen literal
- OpenAPI regenerated
- fixtures are relative to the run instant; a fixed date slides out of a
  relative window and rots the test into a false green
2026-08-08 12:43:51 +02:00
Marc-André Bombeck 84dae1668a docs(mood): say what the seasonal floor gates, and what it deliberately does not
It gates the flag the forecast publishes, so a surface asking whether the
history supports a weekday claim about THIS comparison asks one place.
It does not retro-gate the weekday board the insights page has carried
for releases — that board is built from the entry aggregates and has its
own floors, and hiding it from accounts under ninety days would take a
surface away from people already reading it to enforce a rule about a
different calculation. And it does not justify building a second one.
2026-08-08 12:38:27 +02:00
Marc-André Bombeck 46dc31d10e fix(notifications): offer no completion action on an appointment nudge
An appointment reminder rides the same MEASUREMENT_REMINDER event type as a
checkup — one engine, deliberately — so every channel attached its Done /
Later affordance to it: the Telegram inline keyboard and the APNs category
whose actions the iOS app registers. Both resolve the reminder by id, and
every by-id reminder surface refuses an ENCOUNTER-origin row, so each button
could only answer "not found".

The producer knows the origin, so it says the dispatch carries no action and
each channel drops its own affordance rather than re-deriving the rule. The
reminder id still rides the metadata: it identifies the nudge for the
client-managed suppression tag, and what is withheld is the offer to act.

- suppressActions on the notification payload, documented where it is declared
- Telegram builds no measurement keyboard for a suppressed dispatch
- APNs withholds the category and strips the reminder id from the iOS payload
- a negative control keeps a checkup nudge actionable
2026-08-08 12:37:21 +02:00
Marc-André Bombeck 13d808ccff fix(mood): bound the fit's read, and show the day as a date
Two small things the first pass left rough.

The fit's window read had no row cap. A year holds 365 days and the fold
keeps one entry per day, so the cap only bites on an account that logs
many times a day — but an unbounded read in a nightly job is a table
scan waiting for the account that has years of history. The rows now
arrive newest first under a cap, which also puts the loss on the right
end: an account over the cap loses its oldest days, never the recent
ones a forecast is written for.

And the card showed the raw day key. It goes through the account's own
date-order preference now, pinned to UTC like every other day key in the
app, so this card cannot show a different day from the list beside it.
2026-08-08 12:37:14 +02:00
Marc-André Bombeck 09bf0a1947 fix(openapi): publish the sharing refusal on the prognosis read
Every delegable operation states, in the contract, that it can refuse a
delegate — and the guard over the document caught this one publishing
only its module gate. One status, two reasons: the module gate's own
sentence first, the shared refusal spliced on the end, which is the
shape every other gated delegable route already uses.
2026-08-08 12:30:13 +02:00
Marc-André Bombeck f599eba671 Merge pull request #743 from MBombeck/feat/encounter-phase-1
Add the visit record, its carriers and the appointment arm
2026-08-08 12:25:16 +02:00
Marc-André Bombeck a83be074ca Merge branch 'main' into feat/encounter-phase-1 2026-08-08 12:22:04 +02:00
Marc-André Bombeck 5c25bde822 Pin the orphan sweep in both directions
The sweep that retires an appointment reminder no visit claims had no
test of its own. Two now, because the rule has two halves and only one
of them is obvious.

It reaps a live row nothing points at, and it must leave a row whose
only claimant is a soft-deleted visit. That second half is the subtle
one: the sweep matches on the relation and does not read the visit's
tombstone, so a deleted visit still owns its nudge and restoring the
visit finds it intact. A future change that "tidied" the predicate by
also excluding tombstoned visits would destroy that on the next
unrelated write, and nothing else would notice.

The visit is tombstoned directly rather than through the delete route,
which retires the reminder deliberately and would have proven nothing.
2026-08-08 12:20:19 +02:00
Marc-André Bombeck d45cedcf5d docs(changelog): note the mood comparison and its refusals 2026-08-08 12:15:44 +02:00
Marc-André Bombeck c33f7f9f6a feat(mood): let the Coach read the deviation, and publish the resolved forecast
The snapshot gains the day's two readings with the age of the second
one, through the same reader the page uses, so the page and the
assistant cannot disagree about what today's comparison is. The
freshness rule is the shared one: a comparison about the day before
yesterday carries the date it is about and an instruction not to state
it as today. Absence is a present field rather than a missing key —
a model told "there is none yet" says so, and a model told nothing
estimates one. The wording rule travels with the values, because a model
handed a number and no instruction writes the sentence its training
suggests, and about a forecast that sentence is a causal one.

The contract publishes the resolved value: the expected figure, the
band, the count, the deviation against the band, the age, and the
contributions that carried weight. Nothing is left for a client to
re-derive — two clients deriving the same figure about somebody's health
record is how they start disagreeing about it. The description carries
the observational wording and the display rule: the count goes wherever
the value goes.

The end-to-end proof runs against real Postgres and reads the table
rather than a return value: nothing written a day short of the
threshold, a banded and counted row on the day it crosses, the
provisional label dropped at sixty days, the rows cleared when the
module goes off, the rows cleared when entries are deleted back below
the threshold, and an older pass's row replaced rather than left beside
a fresh one. A delegate holding only the mind section is refused, and
the assertion is on the response bytes.
2026-08-08 12:15:18 +02:00
Marc-André Bombeck f48afed82d feat(mood): show the day's own rating beside what the account's days imply
Two readings, side by side, never merged. The person's own rating leads
— larger, first, and returned exactly as it was stored — and the
comparison sits beside it with its range and the number of days it was
built from. The deviation is measured against the range, not against its
midpoint: inside it is a day that went as the account's own past days
suggest, and only outside it is worth a sentence.

The sentence is a counterfactual and says so. Never "your mood is 5.4",
always "a value around 5.4 would have been expected". The explanation
names what the comparison weighted for that day, out of what the job
stored for that day, in the words the capture sheet already uses — a
person reads "Workload" here because that is what the slider they moved
was called.

The route declares the whole record rather than the mind section, and
the reason is written at the declaration: the comparison reads the day's
context AND the figures the sleep, activity and recovery modules hold,
and it names them, so a delegate holding only the mind section would
receive a derived view of sections the owner declined to share. A scoped
grant is refused outright rather than served a contribution list with
the linked features quietly removed, which would read as "only the mood
fields mattered" and be false.

The causal-verb ban is a test now rather than a review habit: the copy
guard reads all six bundles and fails on a causal construction in any of
them, and on a statement that quotes a value without the days behind it.
Both halves were watched failing.
2026-08-08 11:43:58 +02:00
Marc-André Bombeck 244e8ffbc7 feat(mood): rebuild the forecast rows nightly, and clear them when they stop being true
A job, never a route. A forecast written as a side effect of opening a
page is a function of browsing rather than of data, and the prognosis
also has to be a value the person cannot overwrite — so one file writes
the table and a guard holds it there.

The cohort is the union of two sets and the second one carries the
weight: accounts with enough recent rated days, plus every account that
already holds rows. Without the second, an account that deleted its
entries would fall out of the sweep and keep last month's answer under a
fresh-looking date, with nothing on the surface to say so.

A pass replaces rather than accumulates. Every account it touches ends
holding exactly the rows that run wrote, or none: below the threshold,
module off, no pattern in the data, no recent day carrying everything
the fit needs — every one of those clears what was there. Per-account
failures are counted and the pass continues; only an all-accounts
failure fails the job.

Runs at 04:35, after the inputs have settled and clear of the geo
backfill on the hour's fortieth minute. The arithmetic is a ridge
regression and nothing else: no provider, no key, no egress, no consent
gate, and the module says so at the top so nobody wires one that does
not apply or assumes one that is not there.
2026-08-08 11:31:39 +02:00
Marc-André Bombeck 64c077f7ac feat(mood): build the day matrix and fit it without letting it see ahead
The target is the day's own pleasantness; the inputs are the other four
self-ratings, the numbers and closed vocabularies of the day's context,
and the figures the sleep, activity and recovery modules already hold.

Nothing is imputed. A day missing one of the selected inputs is a day
the fit does not see, and a feature nobody recorded on most days never
becomes a column in the first place — the coverage floor is what keeps
the complete-case rule from quietly emptying the matrix. A day with no
context row contributes no work-status column at all, rather than a zero
that would read as "worked a regular day".

Columns are standardised against the account's own window, so a person
whose stress never passes six does not have six read as the maximum.
Candidates are screened with the machinery the rest of the product
already runs — Pearson at the shared paired floor, Benjamini-Hochberg at
the shared FDR, the shared effect-size floor on the shrunk estimate —
because a sweep over sixty features at raw p < 0.05 finds three things
that are not there.

The fit is a ridge solved in closed form, validated on expanding windows
in date order. The training range ends exactly where the validation
range starts, asserted on the fold indices and on the day keys, not on
the resulting error: a leaking split passes an error check whenever the
leak is small, which is when nobody looks. The band is the central 80%
of the validation residuals, so it widens when the model is bad instead
of staying confidently narrow.

The window read is the linked resolver's own engine, extended to answer
many days in one query rather than growing a second opinion about what a
Tuesday looked like.
2026-08-08 11:25:34 +02:00
Marc-André Bombeck 339e815527 Keep an appointment reminder inside the visit that owns it
Review of the visit work found the appointment arm leaking in both
directions: rows the visit routes could strand, and a reminder surface
that would act on one as though it were a checkup.

An edit could orphan a live reminder. Pointing a still-booked visit at
the checkup it closed walked the foreign key off the visit's own
reminder, and the next reschedule then minted a second one, because the
re-anchor path asked the key what the visit owned instead of asking the
engine. An orphan fires: the cron reads the reminder table, not the
visit list, so it pushed for an appointment nobody could see, edit or
cancel, and deleting the visit did not reach it. The edit arm now
refuses the contradiction the create arm already refused, ownership is
resolved through the relation, deletion retires the row it owns rather
than the row it points at, and a sweep at the end of every visit write
retires anything left armed that no visit claims -- so the state is
unreachable rather than merely unlikely.

The by-id reminder routes never excluded appointments, and that was the
larger hole. Marking one done committed the satisfy and THEN failed on
the mapper, so every appointment push a person acknowledged answered 500
after the write had landed. An edit could give a spent one-shot a
cadence and turn it into a permanent nudge that appears in no list, or
switch a cancelled one back on. The delete is a hard delete, so somebody
holding only the measurements domain could destroy a row belonging to a
visit in the health background. All five refuse in the lookup now -- an
appointment is not addressable there at all -- and the chat buttons that
mark a reminder done or postpone it refuse it the same way. Nor is an
appointment satisfied by a lab result: the auto-satisfy matcher used to
resolve any free-text reminder from any panel, which would have marked
next month's cardiology appointment attended.

A visit is a seam between four domains, and names were crossing it. The
values never did, but the title of a discharge letter is itself the
sensitive part, and a grant that opened only the health background was
never consent for it. Link labels and dates now follow the target's own
domain; the link is still listed, so a delegate learns the visit
produced a lab result without learning which analyte. Closing a checkup
follows the same rule: it re-anchors a preventive-care cadence, which is
a measurements write, so a health-background delegate files the visit
and the closure is reported as skipped rather than performed in silence.

Three smaller repairs. A checkup now closes at the visit's own instant
rather than at the moment it was typed in, which anchors a backdated
entry correctly and makes correcting a mis-filed visit the no-op it
should always have been. A window ending before today no longer widens
back out to today. And the fallback in a push notification was the raw
enum constant, so a lock screen could read a schema value; it reads the
record owner's own language now, in all six.
2026-08-08 11:24:08 +02:00
Marc-André Bombeck 0aeb63cbeb feat(mood): the minimum-n ladder, as constants with boundary tests
Five rungs — nothing at all below fifteen entries, descriptive hints to
thirty, a provisional forecast to sixty, a plain one to ninety, weekday
and seasonal comparisons above it — plus the occurrence floor a value
must clear before it may be named at all.

Every floor is an exported constant and every one has a test on both
sides of its own boundary, because the failure here is invisible: a
forecast built from eight days renders exactly like one built from
eighty, and nobody reading the screen can tell. Each assertion names the
threshold it is about, so a red says which floor moved.

Absence carries its reason and the count rather than being a bare false,
so a surface can say how far along the account is instead of showing an
error. Two numbers are deliberately not minted here: the paired-value
floor stays `MIN_PAIRED_N` in the correlations module, and the
occurrence floor is the crosstab's own, taken by assignment. A test
reads the file back and fails if either turns into a local copy.
2026-08-08 11:15:00 +02:00
Marc-André Bombeck a118d4810d feat(mood): add the prediction table, with one writer and no writer yet
`MoodPrediction` holds the expected value of a day beside the day's own
rating: one row per account per local day, with a band and the number of
days the fit used. Two tables rather than four columns on the entry,
because the point of the second value is that the person cannot correct
it into agreement with the first — the distance between them is the
finding, and a value the edit form can write is not a second reading.

The table stays out of the disaster-recovery backup and the reason is
written where the completeness guard reads it: every row is recomputable
from the entries, the contexts and the linked modules, and carrying one
would restore a forecast made by a model version that may no longer
exist against data the restore has just moved. It is in the wipe plan,
because a derived row is still a day-by-day statement about somebody.

The write-surface guard lands red on purpose and names what is missing:
`lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer
set is empty where it should hold exactly that one file. The job closes
it. The rest of the guard is green now — no route, page or component
writes the table, and the matcher proves itself against a call broken
across lines, an aliased client, a read and a different model.
2026-08-08 11:10:25 +02:00
Marc-André Bombeck 779b7bcaf1 Merge pull request #738 from MBombeck/feat/mood-level-b
Mood entries carry the day around them
2026-08-08 10:58:03 +02:00
Marc-André Bombeck 61c220b7b8 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:57:59 +02:00
Marc-André Bombeck bcad15c7e1 perf(insights): defer the mood breakdowns and the icon map off first load
/insights/mood was on its 460 KB ceiling. The route paints a calendar, a line
chart, a target card and an assessment; under them sit a dozen breakdown cards
— both tag axes, the influence board, the two crosstabs, the day-context
comparison, four Recharts mini-charts, the correlation cards — and every one of
them was a static import, so a cold mount paid for all of it before painting
anything.

The cluster is its own module now, loaded through next/dynamic with a skeleton,
the way the insights mother page already treats its below-the-hero blocks. It
takes the resolved aggregate as a prop rather than running a second query, so
it cannot draw a different snapshot from the regions above it.

The better-days card follows, for one specific reason: it resolves catalogue
tag icons, and that resolver statically imports about a hundred and sixty
lucide icons. The card sits a screen down and the map now rides the same lazy
group as the breakdowns.

Only mood-only modules moved. Deferring the shared target and assessment cards
was tried and reverted: it bought ten kilobytes on this route and cost
thirty-three on the whole-build total, because both are imported by a dozen
other insight routes and a route-local lazy group duplicates them.

  /insights/mood  459 -> 451 KB gz  (budget 460, unchanged)
  total client JS 3296 -> 3297 KB gz (budget 3310, unchanged)

The payload shape and the section card frame move to a shared module both
halves import; the two structural tests that read the old file now read the
union of the two, so a guard cannot go quiet by pointing at the wrong half.
2026-08-08 10:54:59 +02:00
Marc-André Bombeck ff8b1a61b0 Merge pull request #741 from MBombeck/feat/workout-series-enrichment
Attach a heart-rate series to a workout the server already stores
2026-08-08 10:41:48 +02:00
Marc-André Bombeck 0a3811c7a4 Drop the vocabulary exports nothing consumes
The six per-section type aliases and the inverse-orientation list had
no consumer; the schemas read the key arrays directly and the workLoad
orientation is stated where the rating fields are declared.
2026-08-08 10:28:47 +02:00
Marc-André Bombeck b4051e6db3 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:11:13 +02:00
Marc-André Bombeck b0627cfc5e Merge branch 'main' into feat/workout-series-enrichment 2026-08-08 10:09:47 +02:00
Marc-André Bombeck ed5941d313 fix(mood): read an edited entry's linked day in the entry's own zone
The hook always sent the browser's zone while the route's comment said the
detail view sent the entry's stored one. Nothing was red, because nothing
asserted it: correcting an entry logged in another country looked its day up
under whichever zone the correction was being made from, and the night shown
beside it could be hours out with nothing on screen saying so.

The edit dialog passes the entry's `tz` now. The capture sheet keeps the
browser's and passes no third argument, which is correct rather than an
omission: the entry does not exist yet and will be anchored to that zone.

A legacy row with no zone falls back to the browser's rather than to
Europe/Berlin — the server owns that legacy rule and a guess sent from the
client would override the one place it is written down.

The resolution is a pure helper so both branches can be asserted, and both
call sites are pinned structurally, because the resolution being right is
worth nothing if the dialog never hands it the entry's zone.
2026-08-08 09:59:06 +02:00
Marc-André Bombeck eac40e79d6 fix(api): a bad dimension skips its own bulk entry, as the contract said
The doc comment beside `a1`..`a5` promised that an out-of-range value marks
THAT entry skipped and never the batch, while the fields sat in the batch
schema and answered 422 for all five hundred. The words were right and the
code was not, and a phone draining a year of local history lost the year to
one row.

The five now parse per entry, the same way the day context does; a bad row
reports `invalid_dimensions` and the rest of the batch lands.

The split buys the tolerance and costs a second copy of "0 to 10", so a guard
offers the same value to both schemas and makes them agree at both ends of the
scale and one step outside each.
2026-08-08 09:58:50 +02:00
Marc-André Bombeck 1853a71b18 fix(mood): ask the ownership table who owns a linked metric, and pick one source
The resolver hand-wrote the map and got it wrong in both directions. It gated
steps and active energy on `workouts`, which gates workout SESSIONS and not
ambient movement, so the mood sheet hid a figure every other surface shows. It
called resting heart rate and heart-rate variability core with no owner, while
the table has assigned both to `recovery` since v1.30.22 — including the RMSSD
fallback, pinned there so a ring-only account cannot reach through it. An
account with recovery switched off was served the domain it had switched off,
which made the release note's own promise false.

Both directions now resolve through `moduleForMeasurementType()`, the table the
MCP wire, the correlations reader and the Coach snapshot already share.

Steps, active energy and the two vitals also run through the canonical-source
picker now. Without it a phone and a watch reporting the same day summed twice
and the sheet showed a walk nobody took; the vitals answered whichever row was
latest rather than the picked stream's.

A guard pins the three ownership claims, including that the two vitals share
one module — the block carries one availability flag for the pair, so the day
they diverge that flag becomes a lie and the block has to be split first.
2026-08-08 09:58:37 +02:00
Marc-André Bombeck 88f83104e0 fix(mood): the linked day read crosses sections, so it declares record
The route answered sleep minutes, steps, active energy, resting heart rate,
heart-rate variability and an illness day-log with its symptom keys, and it
declared `mind`. A delegate given only the mind section could walk arbitrary
dates through the one URL and read two sections the owner had declined to
share; no mood entry had to exist for any of those dates.

`record` is the convention for a read that crosses sections, and a scoped
grant never reaches one, so the scoped delegate loses the linked block rather
than receiving a filtered day. A block that silently dropped the sections a
grant does not cover would show a day that looks empty where the truth is that
it is none of the reader's business.

The reason is written at the declaration and in the frozen surface entry,
whose previous justification held only for whole-record grants.
2026-08-08 09:58:22 +02:00
Marc-André Bombeck 3510043eba Merge pull request #742 from MBombeck/fix/backfill-taken-at-slot
Record a backfilled dose at its slot's own time
2026-08-08 09:53:55 +02:00
Marc-André Bombeck 4f3a790ce7 test(workouts): pin the losing side of a concurrent enrichment
Two requests can probe the same workout, both find no heart-rate series,
and both mean to attach one. The unique index on `workout_id` picks a
winner; the loser has to report the duplicate it turned out to be rather
than claim an attach the database never made. Nothing could catch that
arm going wrong: the unit fake echoes every row back as landed, and the
race suite carries no series at all, so replacing the status with a
constant `enriched` left the whole suite green. Two concurrent posts of
the same enrichment now assert one `enriched` against one `duplicate`
and a single stored series.

The published contract also says now what the counters mean, instead of
leaving it in a comment: an entry reported as `enriched` counts towards
`duplicates`, because the top-level counters count workout rows and an
enrichment writes none. Field descriptions on the response and on the
per-entry status and reason, OpenAPI regenerated.
2026-08-08 09:53:09 +02:00
Marc-André Bombeck f3c84018f9 Record a backfilled dose at its slot's own time
Marking an older missed dose as taken from the Verlauf pinned the
right slot but stamped the intake with the current moment. The body
builder is a pure helper now, anchored on the slot instant, matching
both the optimistic paint and the skipped-to-taken flip that already
behaved this way.
2026-08-08 09:38:30 +02:00
Marc-André Bombeck 21a14ea679 docs(workouts): drop the version guess from the enrichment notes
The release these land in is not cut yet; naming one in a code comment
would be a number nobody verified.
2026-08-08 09:28:12 +02:00
Marc-André Bombeck 508d44633e fix(workouts): one series, one enrichment, when a batch names a workout twice
Two entries carrying the same external id and both a `samples` array
reached the enrichment arm one after the other, and both read the same
landed workout id back, so the response claimed two attaches where the
database had written one. The second entry now takes the duplicate arm:
a workout is claimed by the first entry of the batch that reaches it.
2026-08-08 09:26:24 +02:00