Commit Graph
5352 Commits
Author SHA1 Message Date
Marc-André Bombeck d24dfd89a9 Drive each new visit mutation through the sharing matrix
The v1.37 matrix demands an executable driver per admitted mutating
handler, not just a line in the inventory: it runs the real route as a
delegate, asserts the effect landed once, and asserts the audit row
names the acting delegate rather than the owner. Eight admissions came
with the visit API, so eight drivers land with them.

The visit fixture is deliberately in the past. A planned future visit
mints an appointment reminder, and a driver that asserted one effect
while producing two would be checking the wrong thing.
2026-08-08 09:19:06 +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 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 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 2f68b3dc79 Merge pull request #737 from MBombeck/release/v1.37.1
Release v1.37.1
v1.37.1
2026-08-08 05:54:20 +02:00
Marc-André Bombeck 035ec054da chore(release): v1.37.1 — five mood values and the range the lab report printed 2026-08-08 05:38:56 +02:00
Marc-André Bombeck 89a749b811 Merge pull request #730 from MBombeck/refactor/settings-admin-one-piece
Settings and admin, one piece
2026-08-08 05:36:10 +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 9e89f3ec5d Merge pull request #736 from MBombeck/ci/e2e-shards
Run the browser suite as two shards
2026-08-08 05:18:30 +02:00
Marc-André Bombeck ea4d284cc9 ci: carry the report per shard and point the guard at the running job
The determinism guard reads the job that runs the browser, which is now
the shard matrix rather than the gate that only carries the required
check name. The report artifact is named per shard so the two uploads
cannot clobber each other.
2026-08-08 04:45: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 8494557a3a ci: run the browser suite as two shards
The single job passed at about eighteen minutes against a twenty-minute
cap, and the settings geometry measurements pushed it over, so the run
died at the timeout with everything to that point green. Same remedy as
the integration suite, split rather than raise: two Playwright shards,
each with its own database and production build, and a gate job carrying
the exact name branch protection requires that fails when any shard
failed or was cancelled.
2026-08-08 04:22:13 +02:00
Marc-André Bombeck 1e72b24477 Merge remote-tracking branch 'origin/feat/mood-level-a' into feat/mood-level-a 2026-08-08 04:02:53 +02:00
Marc-André Bombeck 5e1f5aa9ea refactor(mood): keep the trend windows in one place
The dead-code sweep found the window type exported and unread. It was
unread because the surface that offers the choice carried its own copy
of the same three numbers: the chart could not import the calculator's,
since that module reaches the database client two hops down and a client
component pulling it in would ship Prisma to the browser.

Both now come from the dimension table, which is pure by construction
and which the chart already imported. One declaration of 7 / 30 / 90,
read by the calculator that averages over them and by the control that
offers them, so the two cannot drift.
2026-08-08 04:02:34 +02:00
Marc-André Bombeck fec15e8fed Merge pull request #734 from MBombeck/fix/wipe-geo-truths
The admin wipe wipes, geo stays on the host, and two docs tell the truth
2026-08-08 03:54:02 +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 9da876686d Merge pull request #725 from MBombeck/chore/transparency-ci
Scheduled CodeQL, Trivy and Scorecard workflows
2026-08-08 03:05:25 +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 4db0f3a62a fix(mood): say which age governs a claim about a dimension
The snapshot stamps every metric block with an asOf built from the
newest reading in it, which for mood is the newest ENTRY on the 1-5
axis. Somebody who taps a face each morning and opens the sliders once
a week therefore carries a block stamped as current and a stress
reading six days old, and a reader taking the block stamp as covering
the dimensions states last week's answer as today's.

The block now names the precedence in one line beside the dimensions,
and each dimension keeps its own age. Present only when there are
dimensions to explain.
2026-08-08 02:57:40 +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 d3577d2557 test(mood): cover the level-A blocks at the composition that ships
The staleness test rebuilds the block shape from the pure calculator, so
it proves the calculator and the freshness rule agree and nothing else:
hardcoding the freshness flag in the feature builder, deleting the
notice that goes with a stale value, or dropping the whole block left it
green. This calls extractFeatures against real rows instead.

Both failures were watched. With the flag hardcoded true the unit test
still reported four passing while this one named the six-day-old stress;
with the notice removed it named the missing instruction.
2026-08-08 02:48:53 +02:00
Marc-André Bombeck 370d703d03 Merge branch 'main' into chore/transparency-ci 2026-08-08 02:46:18 +02:00
Marc-André Bombeck 1c135dfe48 Merge pull request #726 from MBombeck/feat/lab-source-reference-ranges
Carry the reference range printed on the lab report
2026-08-08 02:46:16 +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 abb0f2e82c Merge pull request #728 from MBombeck/fix/sync-range-validation
Sync writers refuse readings the app itself calls impossible
2026-08-08 02:27:08 +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 3c6b1b7001 Merge pull request #727 from MBombeck/fix/mood-backup-completeness
The backup carries the whole mood entry
2026-08-08 02:07:59 +02:00
Marc-André Bombeck 5d98a98c21 refactor(mood): drop the orientation helper until something needs it
The inverse flag is read by the trend legend and by the assistant block;
the function that flips a value through it had no caller. It comes back
with the surface that needs a combined view, not before.
2026-08-08 02:07:21 +02:00
Marc-André Bombeck 0c017ba335 docs(changelog): note the five mood values 2026-08-08 02:05:50 +02:00
Marc-André Bombeck 30f35cfcaf Merge branch 'main' into fix/mood-backup-completeness 2026-08-08 01:48:51 +02:00
Marc-André Bombeck 77b3eaf10f Merge pull request #731 from MBombeck/fix/nanoid-advisory
Float nanoid past an infinite-loop flaw
2026-08-08 01:48:47 +02:00
Marc-André Bombeck 8084c97472 docs(score): state why daily mood stays out of the wellbeing pillar
The pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers behind
every threshold. A daily self-rated slider is now available on nearly
every mood row, so leaving it out reads as an omission unless the
reason is written down: the two are different kinds of evidence, and
averaging them would weaken the line that makes the figure worth
showing to a doctor. Recorded at the pillar and at the columns.
2026-08-08 01:44:50 +02:00
Marc-André Bombeck a6bb4f6577 feat(mood): give the assistant all five values, each with its own age
- the level-A blocks ride the mood aggregate the coach and the briefing
  already share, so the ages are fixed once in the data preparation
  rather than once per sentence
- every block carries its newest value, the day it belongs to, how old
  it is and whether that age still backs a present-tense claim, through
  the same freshness rule the vitals surfaces use
- a stale dimension carries the instruction in words as well as in a
  flag, because that is the sentence this exists to prevent
- the 1-5 scale line is untouched; the new axis describes itself, with
  the anchors resolved from the bundle the person read
- the trend page and the assistant run the same calculator, so a number
  in a sentence cannot disagree with the number on the chart
2026-08-08 01:43:32 +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 42c2d8d140 Merge branch 'main' into fix/nanoid-advisory 2026-08-08 01:25:40 +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 1533afdc9d Merge pull request #733 from MBombeck/fix/cycle-test-evening-bomb
Pin the cycle fixtures' clock to the suite's clock
2026-08-08 01:18:34 +02:00
Marc-André Bombeck 7eaede257e feat(ui): add a slider primitive for bounded numeric entry
- Slider wraps the Radix control the way Switch and Progress already do;
  no new dependency
- SliderField adds the label, the value and the two scale anchors, which
  are what make a 7 mean anything
- a null value renders as unanswered rather than as the value the parked
  thumb sits on, and zero renders as the answer it is
- the announced text carries the scale, not only the number
2026-08-08 01:12:52 +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 cb12adcccf docs(security): correct the image publish trigger
The supply-chain section said the publish workflow runs on every push to
main. That trigger was removed in v1.4.34, when the squash-merge and the
release tag each kicked off their own build of the same SHA; the
workflow has fired on v* tags only since. Pull requests still build the
image, and do not publish it.
2026-08-08 01:06:24 +02:00
Marc-André Bombeck 0eac84aa95 fix(geo): read the offline databases first
The resolver queried the online provider first and read the mounted
GeoLite2 databases only when that missed. Every document describing the
feature said the opposite, including the self-hosting runbook and the
admin notification that tells an operator their login IPs are leaving
the server. So an operator who mounted the databases in order to stop
that egress kept all of it, and nothing said so: the location resolved,
the admin overview filled in, and the only difference was where the
address had been.

Now the local databases answer and the provider only ever sees an
address they could not place. A host with the City database mounted and
a hit makes no request at all — including when the ASN database is
absent, because reaching out for a carrier string would send the address
off the host anyway, which is the thing the mount was for.

IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the
online step only, so an offline hit still resolves and an offline miss
returns nothing rather than going online.

The wide-event warning on the fallback path named ipwho.is even when the
operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the
host the runtime actually queries. The admin notification copy needed no
change — it fires only when the databases are absent, and then every
lookup does go online.

The two cases in geo-asn.test.ts that pinned the old order are the
watched red for this change.
2026-08-08 01:05:44 +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 643d8a5a00 feat(mood): map existing entries onto the pleasantness scale
- the migration fills mood_a1 from the five-point label for every row
  that has none, and leaves the other four columns empty because there
  is nothing in a label to derive them from
- guarded on the column being empty, so re-applying the migration is a
  no-op rather than an overwrite of an answer somebody gave since
- the test reads the CASE arms back out of the .sql and compares them to
  the runtime map in both directions, so neither can drift alone
- rows are asserted by value after the statement runs, and again after a
  second run, including one hand-set value that must survive it
2026-08-08 01:03:05 +02:00
Marc-André Bombeck 0849d59ecf test(cycle): pin the fixture clock to the suite's clock
Two cycle suites compute their relative day keys with local Date arithmetic
under TZ=UTC while the routes resolve today in the user's zone, falling back
to Europe/Berlin when the fixture sets none. For two hours every evening the
two clocks disagree about the date and every day-of-cycle assertion is off
by one — the suite was red from 22:00 UTC to midnight and green the rest of
the day. The fixtures now carry an explicit UTC timezone so the test's clock
and the route's clock are the same clock at any hour. Reproduced red at
23:07 UTC, green with the pin at 23:19 UTC.
2026-08-08 01:02:08 +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