Commit Graph
944 Commits
Author SHA1 Message Date
Marc-André Bombeck b97988279e feat(vaccinations): wire the query keys, the contract and the consent copy
- a query-key module and a dependent bundle that also evicts the
  preventive-care root, because logging a dose settles a booster and the
  checkups list goes stale in the same tick
- the OpenAPI module publishes the resolved series with the contract
  written into the description: render text from these numbers, never
  recompute them
- the health-background consent sentence now names the immunization
  history in all six languages, so the widening is stated where it is
  agreed to rather than inferred from a code comment
- no server-side cache helper: nothing cached reads a dose, and a sweep
  over caches nobody reads would look like coverage without being it
2026-08-08 14:43:59 +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 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 a83be074ca Merge branch 'main' into feat/encounter-phase-1 2026-08-08 12:22:04 +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 b4051e6db3 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:11:13 +02:00
Marc-André Bombeck 008de4fc6c Merge pull request #740 from MBombeck/fix/drop-workout-why-button
Drop the coach launch button from the workout detail
2026-08-08 09:23:06 +02:00
Marc-André Bombeck b61b53263e Drop the coach launch button from the workout detail
The page ended on a "Why?" button that opened the coach with a
prefilled question about the session. It leaves on the maintainer's
call; the coach remains reachable from its own surfaces. The two
strings it carried leave all six locales with it.
2026-08-08 09:07:38 +02:00
Marc-André Bombeck d1fa0d9d52 Name the mood sliders for what they ask, not how many they are
The capture section and the insights card called the five sliders
"Five scales", a count where a name belongs. They now carry the
state-of-mind wording in all six languages, with Spanish diverging
from the first slider's own label so the block and the mood slider
stay distinguishable.
2026-08-08 08:34:53 +02:00
Marc-André Bombeck 4a1bca0761 Publish the visit contract and name visits at the point of consent
Query keys land in the central factory before any surface reads them,
because the lint rule that bans a bare array means the first component
listing visits cannot compile without a slot. There is deliberately no
server-side cache invalidator beside them: nothing cached reads a visit
yet, and a sweep over caches nobody reads would look like coverage
without being any. The module says so at the place a reader would go
looking.

The OpenAPI table publishes seven paths with everything resolved — the
practitioner as an object rather than an id, each link with its label
and date, the appointment's next due instant computed. What it does not
publish is the reminder origin enum, which still reads with two members:
the third value exists in storage and reaches no response, because every
preventive-care read excludes it. Keeping that contract closed is why
the exclusion is worth a guard.

Classifying visits under the existing health-background domain widens
what that domain grants. Somebody who shared it so a relative could see
an allergy list now also shares every appointment and the address book.
That is stated where it is agreed to rather than left in a code comment:
the consent screen's sentence names visits and practices, in all six
locales, and the activity feed gains a verb line for each of the eight
new delegated actions so an owner reads what was done rather than
'made a change'.
2026-08-08 08:11:54 +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 93495af788 feat(mood): compare the day context against the day's mood, with counts
- one comparison per context value, reusing the Welch test, the day floors and
  the Benjamini-Hochberg family the tag board already runs; a wide sweep
  filtered on a raw p < 0.05 would surface one-in-twenty noise as a finding
- two entries on one day count as one day
- every statement names how many days it compared, on the same surface as the
  claim, and says it shows a connection rather than a cause
- the assistant snapshot carries the same rows with that constraint written
  out beside them
2026-08-08 07:40:07 +02:00
Marc-André Bombeck 0474b7161d feat(mood): four context sections that cost nothing while closed
- work, contacts, leisure and one notable event, each a closed accordion with
  a count badge, on both the capture sheet and the edit dialog
- sleep, activity, vitals and the illness journal render read-only beside
  them, each row naming where its figure comes from and offering a way in to
  change it there
- the body line links into the illness module and captures nothing
- six locales for every label and every anchor
2026-08-08 07:30:26 +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 dbfc0d8145 feat(mood): the closed context vocabulary and its six locales
- one module holds the four sections, their key lists and the rating fields;
  the Zod enums, the capture UI and the analytics all read it, so a value the
  vocabulary does not name cannot be stored
- labels state facts, never judgements, and the contact circles are groups of
  people rather than a score
- a pair guard binds the arrays to all six bundles in both directions, because
  the reverse-coverage matcher sees a `mood.context.` template and stops being
  able to tell a live label from a stale one
2026-08-08 06:57:36 +02:00
Marc-André Bombeck c6246092ff Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 05:20:04 +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 6dd70f532a Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 03:05:27 +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 b4b39e9088 feat(mood): trend the five values on the insights page
- one engine: the dimension series is a sibling calculator of the mood
  aggregate hub and rides the same bounded read, so no second summary of
  mood can disagree with the first
- 7 / 30 / 90-day means per dimension, plus the daily series behind them
- a dimension nobody answered is named as not recorded rather than drawn
  flat through the middle of the scale, and a zero counts as an answer
- each line keeps the orientation the person answered in; the legend
  says which way the inverse one runs
- the card rides the existing module-gated aggregate read and query key
2026-08-08 01:34:59 +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 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 e532210b21 Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 00:06:26 +02:00
Marc-André Bombeck 72c5181218 Merge branch 'main' into feat/lab-source-reference-ranges 2026-08-08 00:06:22 +02:00
Marc-André Bombeck 23eae2823e refactor(i18n): shorten the five descriptions the gate could not see
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.

Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.

`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.

The five, all six locales:

- full backup — the twelve-item enumeration was the description. It is now
  the card's opening body paragraph, where a list of what is in the file
  belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
  review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
  paragraph rather than riding the page explainer.

Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
2026-08-07 23:20:12 +02:00
Marc-André Bombeck 5a103ea096 test(e2e): measure that Settings and Admin read as one surface
Thirty-four routes, both consoles, English and German, desktop and phone.
The claims this program makes are geometric, so they are checked with
geometry rather than by looking at screenshots.

What it found, which is the reason it exists:

- `SettingsCard as="section"` sat one spacing step tighter than its
  neighbour on every desktop width. The `<Card>`-backed variant inherits
  `gap-4 md:gap-6`; the landmark variant wrote a flat `gap-4`. Seventy-five
  of eighty-six cards measured 24 px and eleven measured 16 px, directly
  above and below each other. The expected value in the spec is a function
  of the viewport for exactly this reason — a constant would have agreed
  with whichever half it was written against.
- One German page subtitle wrapped to a second line where English fit on
  one. Shortened in all six languages.

After both: heading top 24.0 px on every route, spread 0.0 px, in both
languages and at both widths — that is the no-jump claim, measured. Card
header-to-body distance equals the card's own gap on all 86 cards, and no
card body sits off the card's inner edge.

The header's HEIGHT is pinned only where one line is achievable. At 393 px a
70-character sentence needs about 380 px of column, so most explainers wrap
there; the spec asserts the wrap away at desktop width and only forbids a
third line on a phone. The alternative is truncating the sentence, which is
the defect this program removed.
2026-08-07 22:16:31 +02:00
Marc-André Bombeck fd2bd67ff3 feat(labs): keep the reference range a report prints beside a value
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.

Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.

`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.

The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.

Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.

The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
2026-08-07 21:40:41 +02:00
Marc-André Bombeck 18edd34c33 fix(backup): restore the mood note, zone, counter and binary tags
`moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`,
`syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of
them optional or defaulted, so a backup file written before this parses
unchanged and restores exactly as it did.

The restore writes them. The note follows the measurement pair: ciphertext
verbatim when the file carries it, a portable file's plaintext re-encrypted
on the way in, legacy column null either way. `tz` absent still means the
legacy Europe/Berlin reading, which is what those rows meant. `syncVersion`
comes back at the value the file recorded — restoring at the default handed
the next round to any paired device holding a higher number, and the device
would then overwrite the recovered entry with what it had.

The link rebuild re-creates both arms: rated factors from `factors`, ticked
tags from `structuredTags` with a NULL rating. An unresolvable tag key is
reported under its own `moodTag` family, beside the rated one, because an
operator needs to know which of the two an entry lost.
2026-08-07 21:37:06 +02:00
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.

Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.

Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.

On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.

The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
2026-08-07 20:53:05 +02:00
Marc-André Bombeck 8a1585a391 Merge pull request #723 from MBombeck/fix/cycle-correctness
Cycle correctness: overdue verdicts, backfills, deletes that leave no torso
2026-08-07 19:58:39 +02:00
Marc-André Bombeck 330f60115a refactor(admin): the console speaks the Settings vocabulary without an accent
Twenty-six of twenty-eight admin files already used the Settings card and
header. What made the console read as its own system was everything on top of
that: three hand-rolled cards, a header slot that meant five different things,
and a heading the frame took but never painted.

- `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed
  both into a component that has rendered neither since v1.18.6.1 — the shell
  reads the same keys itself. A prop contract nothing honours is a promise the
  next reader believes.
- The invite card, the four host-metric headers, and the `/admin` welcome
  card stop hand-rolling `bg-card border rounded-xl` shells with their own
  padding steps and their own heading weights. `text-sm` as a card title on
  the first card of a page, directly above a correct one, was the first thing
  the eye landed on.
- The header status slot carries status. The backups run, the invite create,
  the danger-zone wipe, and the user filters come out of it; the app-log
  refresh stays but reads as the secondary it is.
- Loading states paint their card and header. Encryption returned a bare
  spinner row and then a card, so the section jumped when the status landed —
  the fix `coach-feedback-section.tsx` already carried, applied where it was
  missing. Same for the notification-status card.
- One label/value tile. `StatusItem`, the encryption section's private `Stat`,
  the user-management inline panels and the backups upload box were four
  fills and three paddings for one role.
- The overview stopped printing the running version, build SHA and build time
  twice, thirty pixels apart. The version tile owns build identity — it is the
  one that checks for a newer release; the snapshot owns what is running.
- `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot;
  one sentence stays there and the two that carry information move into the
  body as foreground. The central-codex consent copy moves the same way, and
  its disconnect is `variant="destructive"` — which `ConfirmButton` refused to
  accept, so every destructive confirm in the tree had been hand-tinting an
  outline instead.
- Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on
  text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty
  state repeating the header's CTA, and the `mt-6` a card added inside a
  frame that already spaces its children.
2026-08-07 19:55:47 +02:00
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
2026-08-07 19:39:11 +02:00
Marc-André Bombeck ce9b2814e6 feat(dashboard): reach shared records from the account switcher, not a card
The "looking after" block sat directly under the greeting and above this
account's own day, permanently, for anyone with a single shared record. The
dashboard is this account's day; other people's records are a destination,
and the account switcher in the top bar and the sidebar already is that
door — with the same access level and record kind on each entry.

Drop the card, its test, and the five strings only it used, across all six
locales.

The access-payload consumer guard named that one component as the reader of
`level` / `sections` / `recordKind`, so it went red on the deletion, which is
what it is for. It now names the surfaces that actually adopt those fields —
the presentation resolver and the switcher — and matches the property access
rather than one variable name, so a rename of the binding cannot make it
match nothing and pass. Verified by removing every reader: it fails.

Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all
six bundles in one go.
2026-08-07 19:24:46 +02:00
Marc-André Bombeck 626ec203ac Merge branch 'main' into fix/cycle-correctness 2026-08-07 18:24:37 +02:00
Marc-André Bombeck 61501500b9 Merge branch 'main' into feat/workout-hr-profile 2026-08-07 18:24:35 +02:00
Marc-André Bombeck 41faf165ee refactor(anamnesis): give each lifestyle entry a proper action row
The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.

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

Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
2026-08-07 16:45:17 +02:00
Marc-André Bombeck 72cd21352c refactor(mental-wellbeing): shorten the intro and drop the mood-history link
The intro listed all four instruments with their subject in brackets and
then explained itself, which ran to two lines on a desktop header and five
or six on a phone — a paragraph to read before reaching the four cards that
say the same thing. It keeps its opening claim and the one caveat that
matters, and loses the enumeration the cards below already carry: one line
on desktop in all six languages, measured at 1280 and 1440.

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

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

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

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

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

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

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

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

Refs #715
2026-08-07 16:29:58 +02:00
Marc-André Bombeck bd6089734d fix(cycle): let the month grid read the month it is showing
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.

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

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

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

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

- calendar days carry isCycleStart, so the delete dialog can say that
  removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
  400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
  read, and comes back complete on a window over its month
- new strings in all six languages
2026-08-07 15:20:29 +02:00
Marc-André Bombeck cea330bf70 Say when a session has heart-rate figures but no profile behind them
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.

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

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

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

Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
2026-08-07 14:50:03 +02:00