Commit Graph
1251 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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
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
Marc-André Bombeck 3d55289ba3 feat(workouts): attach a heart-rate series to a workout already stored
A batch entry matching a stored workout stays first-write-wins for the
workout row. What it can do now is hand over a heart-rate series the
stored workout does not have yet: the series is attached, the row itself
is not written at all, and the entry reports back as `enriched`. A
workout that already carries a series is a no-op, so a client can walk
its whole history as often as it likes.

- The pre-flight probe now answers both questions at once, whether the
  row exists and whether it already has a series, and carries the id so
  the attach addresses the stored workout without a second round trip.
- The attach rides the same child write as a fresh workout's series.
  RETURNING names the rows that landed, so a workout that gained a
  series between probe and write reports as the duplicate it is instead
  of claiming an attach that never happened.
- A `samples` array that fails validation is refused per entry with
  `reason: "invalid_samples"` rather than failing the batch. One
  unusable session out of a hundred must not stall a history sweep the
  client cannot repair by retrying; every other validation failure keeps
  failing the batch.
- The wide event and the audit row tally enrichments. Counts only, the
  series never reaches either.
- `enriched` is the one contract movement: additive on the per-entry
  status, request shape unchanged. OpenAPI regenerated.

Integration coverage against a real Postgres pins the attach, the
repeat as a no-op, the refusal of a bad series alongside entries that
land, a mixed batch reporting each status, and the stored workout row
compared whole before and after, `updated_at` included.
2026-08-08 09:15:44 +02:00
Marc-André Bombeck 7b1a44e799 chore(mood): account for MoodContext in the four whole-schema guards
- the wipe plan names it before its parent, so the confirmation screen's count
  is the number of rows that existed rather than zero
- the backup plan classifies it as carried and two-ended, and the round-trip
  registry seeds one, which is what makes that claim provable
- the sharing surface admits the linked-day read with the reason it may answer
- CHANGELOG for the release
2026-08-08 08:10:15 +02:00
Marc-André Bombeck f158494def Add the visit and address-book API, and the appointment arm
Nine visit routes and five practitioner routes, all classified under the
existing profile domain and each reviewed into the frozen sharing
literal with the sentence that argues for it. An address book of a
person's own doctors is record content, not account configuration: it
names no credential, no integration and no notification channel, which
is the fence that classification turns on. The eight sharing domains are
unchanged. Link and unlink stay bare, matching document link and unlink,
and the pair is revisited as one decision later.

Nothing is a required field. A visit saves with a date and nothing else.
Every id a body may carry is re-narrowed to the caller before anything
is written, and an unknown link id never blocks a save.

A booked visit mints exactly one reminder on the existing engine with
the new origin, and the appointment half is the part worth reading
twice. Both cadence columns stay null, which is the schema's one-shot,
so the first due instant is stamped directly and the never-nag property
is structural rather than guarded: after it fires, the cadence helper
has no occurrence to return. Rescheduling re-anchors that row and never
mints a second, which the tests assert by counting rows rather than by
checking the date moved.

Filing a visit as done closes the checkup it was filed against, on the
transition rather than on every later edit. Writing the test first found
that: satisfying is forward-only, so re-saving a completed visit was
pushing the checkup's next due date out again as if the person had been
a second time.

An appointment must appear on no preventive-care surface, and that is
four separate reads, one of them in the assistant tooling. Each carries
the exclusion, a guard proves all four by source text with a non-zero
match count, and the reminder mapper refuses such a row outright — so a
read site that loses its filter fails loudly instead of showing an
appointment as a checkup. The published origin enum is untouched.
2026-08-08 07:51:29 +02:00
Marc-André Bombeck 8f263e4767 feat(api): publish the mood dimensions and the day context on the wire
- /api/sync/changes carries a1 to a5 and the day context under the same keys
  the write path accepts, so a client reads back exactly what it can post
- BulkMoodEntry accepts both, all optional; a1 stays server-derived from the
  five-point label, so a build that sends only a label still writes a complete
  row and needs no change to stay correct
- the batch parses each entry's context on its own: one row carrying a value
  the vocabulary does not name is skipped and the rest of the batch lands
- docs/api/openapi.yaml regenerated alongside
2026-08-08 07:46:14 +02:00
Marc-André Bombeck ba21c3a9bc Put both link families behind one service
Three narrow link tables landed with the visit record, which is a step
away from the one-shared-service direction the polymorphic-linking work
argues for. This bounds that step to a single module: every caller now
speaks one vocabulary — a source, a target kind, a list of target ids —
so replacing the storage underneath later is a change in one file rather
than a sweep across every route that files something.

The document-condition links move behind the same signature, so there
are two families and one call surface. Behaviour is unchanged and the
existing vault tests are the proof; where a test double had to follow
the new query shape, it says why in the diff.

The rules that used to live at each call site now live in the module and
are proven against real Postgres rather than a fake that ignores a
where clause: both ends re-read scoped to the owner before a row is
written, a repeated link is a success that changes nothing, a call is
capped at a hundred targets, and a tombstoned record is not linkable on
either end. Unlinking one deliberately still works after a delete, or a
filing nobody can clear would be left behind.

Reads return a resolved label and date rather than a bare id, so a
client rendering a list never re-derives what the server already knows.
The restore is the one documented exemption and writes the tables
directly: it rebuilds an account rather than filing something in one.
2026-08-08 07:26:14 +02:00
Marc-André Bombeck 1f609d42e0 feat(mood): resolve sleep, activity, vitals and symptoms at read time
- one resolver reads the day's figures from the modules that own them; nothing
  is copied onto the mood row, so correcting a sleep session corrects it here
  in the same moment
- absence answers { present: false } rather than zero, because a night nobody
  recorded and a night of no sleep are different facts
- a module switched off blanks its whole block instead of filtering down to an
  empty one
- keyed by the local day so the capture sheet can ask before an entry exists
2026-08-08 07:22:33 +02:00
Marc-André Bombeck 8eb36da228 feat(backup): carry the mood day context through export and restore
- the completeness guard now binds a third model, MoodContext, to a named
  select; the four omitted columns each carry the reason the backup is right
  to drop them
- the disaster-recovery arm carries the ciphertext, the portable arm the plain
  text, and every other column rides in both
- the restore rebuilds the context after its entry, inside the same
  transaction, and a file written before contexts existed restores without one
- the round trip asserts the restored row by value, zero included
2026-08-08 07:14:04 +02:00
Marc-André Bombeck 24425e9aff Add the visit record and the carriers that keep it
A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.

Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.

Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.

Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
2026-08-08 07:14:02 +02:00
Marc-André Bombeck a25ec211cd feat(mood): capture and persist a mood entry's day context
- moodContextSchema derives every enum from the vocabulary, so a value it does
  not name answers 422 through returnAllZodIssues rather than being dropped
- the context is written in the same transaction as the entry and its links; a
  failure rolls the entry back rather than leaving a client retrying a 5xx
  into a duplicate
- an entry with nothing filled in stores no row, and a context that says
  nothing removes the one that was there
- the create, list, detail and edit reads answer the context with its lists
  decoded and its note decrypted; the ciphertext never leaves the server
2026-08-08 07:07:03 +02:00
Marc-André Bombeck 8045540b7e Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
2026-08-08 04:29:27 +02:00
Marc-André Bombeck bd787daf8b Merge branch 'main' into feat/mood-level-a 2026-08-08 03:29:52 +02:00
Marc-André Bombeck cbf949d43a Merge branch 'main' into fix/wipe-geo-truths 2026-08-08 03:29:51 +02:00
Marc-André Bombeck 3dd79ec734 fix(mood): one spelling on the wire, one day-key helper, one written decision
Three small things the audit named together.

The mood reads spread the whole row, so adding the wire keys shipped
every level-A value twice — once as the column name and once as the key
the write path accepts. A client would have picked one and been stuck
with it. The column spelling is stripped now, and the create response's
key set is asserted so it stays stripped. The published contract is
unaffected either way; these routes are not in it.

The dimension series carried its own copy of the day-key subtraction
that the freshness module already exports. One copy now.

And the reason a stale dimension ships with a notice instead of being
dropped the way the status surfaces drop one is written at the builder:
a one-line card has nothing left to say about a value it cannot narrate,
while an open conversation can say when the value is from — and silence
there would leave no way to tell an unanswered dimension from one nobody
has looked at in a week.
2026-08-08 03:03:38 +02:00
Marc-André Bombeck 0a39d50b05 fix(mood): stop offering to clear a value that cannot be cleared
An entry always carries a five-point label, so it always implies a
pleasantness value: the create path derived one from a null and the
capture sheet still showed a clear control for it, which meant a button
the server undid on the way to the database.

One rule on both routes now — a null asks for a value that cannot exist
and gets the derivation, on create and on edit alike — and the control
is gone for that field. The other four are genuinely optional and keep
theirs.
2026-08-08 02:55:08 +02:00
Marc-André Bombeck aa56246ba2 fix(backup): the restore states a level-A value only when the file has one
Matches what syncVersion, syncedAt and the row stamps already do a few
lines above: a key the file does not carry is a key the file predates,
not a key the file says is empty. Reachable when one file addresses the
same row twice, where the second entry now adds without erasing what
the first one set; an explicit null still clears.

Worth stating plainly, because the audit that asked for this expected it
to protect live rows from an older file: it does not, and it cannot. The
restore deletes the owner's whole mood partition before it rebuilds, so
by the time the update arm runs there is nothing live left to protect. A
test now pins that contract too, so nobody reads the omission as a
guard it is not.
2026-08-08 02:52:41 +02:00
Marc-André Bombeck b9a6eeb1d7 fix(mood): a re-post no longer blanks the values it says nothing about
The single create's upsert restated all five columns on its update arm,
so a client re-posting an entry with only a label and a timestamp — the
phone, whose build has no sliders — cleared the stress, energy,
connectedness and stability somebody had answered on the web. The batch
and import paths already preserved them, so the two disagreed.

One answer now, and it is preserve: absence in a request means nothing
to say, an explicit null takes an answer back, and pleasantness still
follows the label because the request always carries one. The create
schema accepts the null that makes clearing expressible.

A re-post that omits a dimension is asserted to leave it standing, and
that assertion has been watched to fail against the old arm.
2026-08-08 02:45:31 +02:00
Marc-André Bombeck 94816d31a8 Merge branch 'main' into feat/lab-source-reference-ranges 2026-08-08 02:27:10 +02:00
Marc-André Bombeck d764a084a7 Merge branch 'main' into fix/sync-range-validation 2026-08-08 02:08:02 +02:00
Marc-André Bombeck 97582dedc0 feat(mood): capture the five values without lengthening the quick check-in
- the five faces are untouched: pick one, save, done in a tap
- a closed section below them opens onto five sliders; picking a face
  suggests a pleasantness value and moving that slider takes the
  suggestion over, while the face selection stays where it is
- an untouched slider writes nothing, and clearing one takes the answer
  back rather than leaving a middle value behind
- the list's edit dialog shows and changes the same five, so nothing is
  written on one screen and invisible on the other
- correcting an entry no longer re-derives pleasantness when the label
  did not move, which used to overwrite a hand-set value on any edit
- factor ratings are no longer offered on a new entry and nothing was
  deleted: existing ratings still render, edit and feed the crosstab
- six locales, and the dimension table's keys are declared dynamic
2026-08-08 01:24:04 +02:00
Marc-André Bombeck 9e44c8754e feat(mood): carry the five level-A values through backup, restore and CSV
- the backup select and the payload carry all five, on both the portable
  and the disaster-recovery arm
- the restore writes what the file holds and nothing else: a file from
  before these columns restores them empty rather than deriving them
- the CSV appends them after the existing columns so nothing shifts under
  a spreadsheet, and an unanswered value is an empty cell rather than a
  zero, which is a real answer on this scale
- the round trip asserts each of the five by value, including a zero
2026-08-08 01:08:27 +02:00
Marc-André Bombeck ef35fe681d fix(admin): wipe every account's data, not nine tables
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.

The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.

- AccountGrant is the one difference, with its reason in the plan: it is
  the only access path to a managed profile, and this action preserves
  accounts by definition. Deleting every grant would leave records
  nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
  did what from where, which is exactly what clearing a box is for; the
  receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
  half-applied wipe is worse than none, because the person is told it
  succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
  which is how nobody noticed: it read the same whether the wipe had
  cleared nine tables or ninety.

Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
2026-08-08 01:05:31 +02:00
Marc-André Bombeck a1c78eceef feat(mood): derive pleasantness on every write path
- one helper resolves the level-A values for a write; the five writers
  call it instead of carrying their own copy of the derivation
- the create schema takes a1 to a5, and a value the user set wins over
  the derived one; the batch request shape is untouched
- both arms of both upserts restate the values, so a re-post cannot
  leave a stale reading beside a mood that has since changed
- the JSON import derives from the row's own label rather than the
  file's score, which is the one place the two can disagree
- correcting an entry's label re-derives pleasantness the way it
  re-derives the score; an explicit null takes an answer back
- a round trip through all five writers against real Postgres asserts
  the rows agree and that nothing invents the other four values
2026-08-08 00:49:15 +02:00
Marc-André Bombeck 72c5181218 Merge branch 'main' into feat/lab-source-reference-ranges 2026-08-08 00:06:22 +02:00