Commit Graph
1758 Commits
Author SHA1 Message Date
Marc-André Bombeck 88123c53da feat(medications): accept and return per-schedule units-per-dose
Add schedules[].unitsPerDose to the create/update contract with the
same whole-number-or-curated-fraction validation as the medication
level, built into the Prisma data field-by-field on both the create
and the schedule-replace path. Unwrap the nullable Decimal to a JSON
number at every medication read/write response through one shared
serializer so the wire shape cannot drift, keeping NULL as NULL to
mean inherit-the-medication-level.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 6770bf3b7f feat(api): add emergency profile read and write route
GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck a730ad188b feat(mcp): answer the visit history over the assistant wire
Add get_visits, a bounded read of past visits (default the last 12
months, optional practitioner-name filter) returning date, status,
kind, practitioner, specialty, the visit's own reason and outcome, and
the linked condition labels, newest first. Absence is explicit: an
account that has never recorded a visit reads { present: false }, never
{ present: true, visits: [] }.

Fold upcoming appointments into get_preventive_care as their own named
field rather than minting a second due-list tool, so a question about
what is coming up gets one answer. The Vorsorge arm stays free of
ENCOUNTER-origin reminders; the appointments are read from Encounter
directly. Reason and outcome ride the USER_TEXT wrapping, and every row
is scoped to the caller by construction.
2026-08-10 02:18:45 +02:00
Marc-André Bombeck fe17d06572 Give mood-tag and cycle-symptom edits an audit row
Editing a custom mood tag or group, and editing a custom cycle symptom,
wrote nothing to the audit ledger, while their delete siblings did. Two
of these paths destroy data: a custom mood tag or cycle symptom purged
with ?purge=true cascades its entry links and unpicks historical
entries, so a destruction left no trace of who ran it or when.

Every vocabulary-mutating verb now records an audit row: create, edit,
and delete for both custom tags and custom groups, and edit for a custom
cycle symptom. Encrypted labels are never logged; the row carries only
the field names touched and the resulting active state. The two
display-only writes on this surface, hiding a catalogue tag and
reordering the layout, stay ledger-free by design and say so inline,
since a per-user display preference is not a health-data write and a row
on every drag-to-reorder would be noise rather than a record.
2026-08-09 14:57:12 +02:00
Marc-André Bombeck 311adcfdda Refuse a manual satisfy of a screening reminder server-side
A Vorsorge screening (PHQ-9, GAD-7, WHO-5, SCI) resolves only from the
score row a completed check-in writes, never from a manual done. The web
and iOS clients enforce this by routing a screening to the check-in page
instead of offering the done action, but the satisfy and complete routes
trusted that gate and would stamp any reminder, screening included. A
crafted POST could therefore mark a screening satisfied with no
assessment behind it.

Both routes now reject a screening reminder with 409 before the shared
satisfy primitive runs, mirroring the client rule as defense in depth.
Typed numeric reminders keep their manual satisfy, which is intentional
and covered. The satisfy route gains its first route test alongside the
new guard case on complete.
2026-08-09 14:18:56 +02:00
Marc-André Bombeck a959db8679 refactor(ui): route settings shells, actions, and empty states through primitives
Bring the remaining settings and dashboard surfaces onto the shared
anatomy: the API and MCP revoke buttons become the destructive variant
instead of an outline hand-tinted red; the runtime-actions row places
its raw-data setting and status lines above the actions, not under them;
the account section paints its card and header while loading instead of a
lone spinner; the share revoke becomes a right-aligned row action rather
than a full-width alarm strip per link; the recent-workouts tile and the
MCP and notifications empty states compose Card and EmptyState instead of
hand-rolled bg-card shells.
2026-08-09 11:27:18 +02:00
Marc-André Bombeck 7f6eae8425 style(ui): pull off-scale spacing and micro-labels onto the scale
Sweep the spacing rests that sat off the 1/1.5/2/3/4/6/8/10 scale: the
health-score panels and their skeleton, the recommendation ask-Coach
gutter, the onboarding tour footer, the vaccination row, the danger-zone
alert margins, the recovery-codes action wrapper, and the chart-tooltip
tile. Move the last hand-written micro-sizes (coach counters, the
personal-record badge, the score-ring label, the dose-history and
cycle-calendar headers, the mood tag chips) onto the text-2xs token, and
give the documents upload buttons the mobile tap floor their peers set.
2026-08-09 11:27:05 +02:00
Marc-André Bombeck b6332a96d9 refactor(ui): route hand-rolled card headers through the shared header primitives
Move the last hand-rolled tile and section headers onto TileHeader and
SectionHeading so every card speaks one header language: the vorsorge
dashboard tile, the three illness cards, the metric-correlation section,
and the custom-metric list group. The mood-chart title drops to the
primitive semibold weight and its bucket label becomes a TagChip.

Align the in-card sub-headings of the integration cards to the one
sanctioned section-label tier (muted uppercase eyebrow) instead of a
second text-sm font-semibold dialect.

Keep the onboarding and mental-health wizard hero H1s at their
deliberate semibold weight, now marked inline as an intentional
exception so a future heading sweep leaves them alone.
2026-08-09 11:03:43 +02:00
Marc-André Bombeck 40e53b14d6 feat(practitioners): search and group the address book by specialty
Search now covers the specialty column alongside name and practice, so a
field name like "Zahnmedizin" finds the practice even when the name and
practice hold nothing of the sort. The same OR reaches the visit form's
practitioner picker, which now shows the specialty beside the practice so
a specialty hit is legible. The list groups its rows under their
specialty, sorted alphabetically with the unspecified rows gathered into
one group at the end; when nothing carries a specialty the headings stay
suppressed and the list reads flat as before.
2026-08-09 06:53:57 +02:00
Marc-André Bombeck 4335b38e02 feat(nutrients): remove in-app water logging, keep the sync path
Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.

The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
2026-08-09 06:46:28 +02:00
Marc-André Bombeck ba42354733 fix(admin): replace hand-rolled read-error boxes with the shared primitive
Eight admin surfaces rendered a failed read as their own destructive-
tinted box, and most offered no way to recover. Sweep them onto the shared
QueryErrorRow with retry so the console uses one error form (§13). The
statically renderable sections were watched fail first against the
hand-rolled boxes.
2026-08-09 04:32:54 +02:00
Marc-André Bombeck 1229d54d30 fix(insights): recoverable read failures on the ECG detail and list page
The ECG detail rendered a read failure as a muted, non-recoverable line;
the routed list page had no error branch at all, leaving the shell
heading over an empty body that read the same as loading. The detail now
paints a QueryErrorCard with retry (an alert is content, not muted meta),
and the page shows the same instead of an empty shell. The overview teaser
still unmounts silently, which is right for the overview. Tests watched
each fail first.
2026-08-09 04:13:46 +02:00
Marc-André Bombeck 6d67e84cea fix(dashboard): localize the respiratory-rate unit label
The respiratory-rate tile hard-coded the English unit while the metric
sub-page already reads the localized string. Point the tile at the same
key so every locale shows its own unit.
2026-08-09 03:59:19 +02:00
Marc-André Bombeck 8d5948ceb3 chore(release): v1.37.3 — the immunization record 2026-08-09 01:55:42 +02:00
Marc-André Bombeck 999206767f Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-2 2026-08-08 23:01:36 +02:00
Marc-André Bombeck 958f3bc50a test(vaccinations): reconcile the structural guards with the new surfaces
- freeze the booster-mint and upload-suggestion routes into the sharing
  surface guard with their why sentences; delegable-write and mutation
  inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
  like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
  success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
  group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
  longer throws for a fully-selected report
2026-08-08 21:43:28 +02:00
Marc-André Bombeck e0692efba8 fix(sharing): widen the grant db slice for the expired-row sweep
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
2026-08-08 21:14:23 +02:00
Marc-André Bombeck 9b58394724 feat(vaccinations): link documents to doses, with the upload suggestion
- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
2026-08-08 21:11:21 +02:00
Marc-André Bombeck b5ce44628a feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
  ordinary VORSORGE reminder keyed on the dose's primary antigen; the
  published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
  reminder rather than minting a second (WR-9 seen red on the reminder count,
  reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
  the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
2026-08-08 21:01:25 +02:00
Marc-André Bombeck eefc151617 feat(vaccinations): add the immunization log route, nav entry and shared-record family
- register /vaccinations in the clinical nav spine, module-gated on
  vaccinations (default-on) and part of the shared record
- join /vaccinations to the profile shared-record route family so a scoped
  profile grant can present the page it is allowed to read
- add the page shell: authenticated, module-gated, with the standard
  module-off redirect the medications surface uses
- add the nav label and the surface title/subtitle/empty copy in six locales
2026-08-08 20:24:42 +02:00
Marc-André Bombeck 4f2dd51390 Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-1 2026-08-08 19:35:18 +02:00
Marc-André Bombeck 08d3cd2c31 Split the ECG cap and one provenance entry off their label maps
The /insights overview crept a kilobyte over its ceiling: the ECG
teaser imported its overview cap from the classification module, which
dragged that module's label map and helpers into the eager page, and
the Health Score card pulled the whole metric-provenance map for its
one citation. The cap now lives in a value-only module and the single
provenance entry is split out, so the overview imports the bare number
and citation it renders. Both routes are back under budget. The
aggregate ceiling rises to hold the six-locale copy the new encounter,
mood-context, mood-prognosis and practitioner surfaces added, which the
whole-build sum counts independently and no single route downloads.
2026-08-08 17:56:40 +02:00
Marc-André Bombeck e1a4b7631c Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
2026-08-08 17:07:58 +02:00
Marc-André Bombeck 4c2dfa57b4 Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish 2026-08-08 16:17:40 +02:00
Marc-André Bombeck f28c6df861 Merge branch 'main' into feat/encounter-phase-2 2026-08-08 16:11:50 +02:00
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

- the account-access resolver publishes the owner's `fullName` alongside the
  greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
  on the argument so the grant-view and actor shapes that never carried it
  keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
  compares it, so the two published views of the active record cannot name
  the same owner differently
- the OpenAPI contract regenerates with the additive field

This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
2026-08-08 16:07:25 +02:00
Marc-André Bombeck 8f3e4b6387 fix(insights): cap the ECG teaser, never the ECG page
The five-strip cap belonged on the overview teaser — a glance on /insights —
not on the surface a user opens to read their history. It was living in the
shared EcgSection, which the dedicated /insights/ecg page reuses, so an
account with many recordings could only ever reach the newest five there and
the older strips became unreachable with no pagination behind them.

The cap is a prop now. The teaser passes ECG_OVERVIEW_LIMIT; the page passes
nothing and lists every recording. The constant lives in the React-free
classification module so the overview page can pass it without pulling the
dynamically-loaded ECG component into its bundle.
2026-08-08 16:06:57 +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 dc56f70961 fix(insights): give an ECG recording its own page and quiet the list
The ECG surface opened with three lines of muted text saying what HealthLog
does not do, before a single strip. Two of them said the same thing — one
from the page's explainer slot, one hand-rolled beneath it — and the third
repeated it under the list. The device attribution on the recording itself
already carries that claim where it means something, so the three go and the
page opens on the recordings.

- the device's verdict rides the timestamp as a tag instead of taking a line
  of its own beneath it. It qualifies the recording; it is not a second fact
- a recording is a route now, `/insights/ecg/[id]`, with the back link ABOVE
  the heading exactly like a workout detail. The detail used to swap itself
  into the list card with its back button under the page title, and the
  recording had no address at all: nothing to deep link, nothing for the
  browser's own Back to return to. It reads by id, so a cold link works
- the list paints the five most recent strips. A watch that records every
  morning turns an unbounded list into a scroll, not a history

`ecg-classification.ts` holds the verdict label map the list, the detail and
the cross-link card each used to carry a copy of.
2026-08-08 15:17:05 +02:00
Marc-André Bombeck 0160052289 refactor(settings): retire Research Mode — the switch governed nothing
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.

The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.

Settings → Advanced is now what it reads as: reset the data, delete the
account.
2026-08-08 15:17:05 +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 82982038cb feat(vaccinations): let a logged dose clear the boosters it answers
- the dose is expanded to its component antigens first, so one Tdap
  entry settles a tetanus booster and a pertussis booster at once;
  matching the preparation's own slug would leave both standing
- it runs inside the create transaction and nowhere else: no cron, no
  ingest hook, and an edit never re-runs it, so correcting a typo in a
  date cannot move a reminder
- forward-only, which is what makes transcribing an old record safe: a
  page from decades ago never clears a booster that is due now
- a free-text-only dose matches nothing; a name string is not evidence
  about what was in the syringe
- an ordinary preventive-care reminder carries no antigen key and is
  structurally out of reach
2026-08-08 14:36:08 +02:00
Marc-André Bombeck 7ac81f3eed feat(vaccinations): publish the routes, classify them, gate the module
- four route files: list and create, read and edit and delete, restore,
  and the link pair; every delegable one is classified as health
  background, in each leg of the sharing guard, with its reason written
- link and unlink stay bare, consistent with the document and visit link
  routes; the three are revisited as one decision
- vaccinations becomes the nineteenth module key, default-on and
  surface-gated like medications: the data routes stay exempt so a
  restore keeps working and re-enabling finds every dose intact
- a delegate's dose entries get their own sentence in the owner's
  activity view rather than reading as a generic change
2026-08-08 14:27:36 +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 6926caa11d feat(vaccinations): carry the immunization log through export and restore
- a new backup section reads the dose and its document link through
  their own delegates and writes both ends in one file, the arrangement
  the visit section already uses
- the practitioner and the visit remap to the rows the same restore put
  back; the booster reminder never travels, so its reference resolves to
  null and the drop is reported rather than swallowed
- the antigen slug restores verbatim: a slug the catalogue has since
  dropped still says what a person was given
- the round trip seeds a dose with every optional column filled and
  reads each one back, so a returning row count cannot stand in for a
  returning record
2026-08-08 13:54:24 +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 a83be074ca Merge branch 'main' into feat/encounter-phase-1 2026-08-08 12:22:04 +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 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 61c220b7b8 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:57:59 +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 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 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