Commit Graph
3781 Commits
Author SHA1 Message Date
Marc-André Bombeck 61c220b7b8 Merge branch 'main' into feat/mood-level-b 2026-08-08 10:57:59 +02:00
Marc-André Bombeck bcad15c7e1 perf(insights): defer the mood breakdowns and the icon map off first load
/insights/mood was on its 460 KB ceiling. The route paints a calendar, a line
chart, a target card and an assessment; under them sit a dozen breakdown cards
— both tag axes, the influence board, the two crosstabs, the day-context
comparison, four Recharts mini-charts, the correlation cards — and every one of
them was a static import, so a cold mount paid for all of it before painting
anything.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The published contract also says now what the counters mean, instead of
leaving it in a comment: an entry reported as `enriched` counts towards
`duplicates`, because the top-level counters count workout rows and an
enrichment writes none. Field descriptions on the response and on the
per-entry status and reason, OpenAPI regenerated.
2026-08-08 09:53:09 +02:00
Marc-André Bombeck f3c84018f9 Record a backfilled dose at its slot's own time
Marking an older missed dose as taken from the Verlauf pinned the
right slot but stamped the intake with the current moment. The body
builder is a pure helper now, anchored on the slot instant, matching
both the optimistic paint and the skipped-to-taken flip that already
behaved this way.
2026-08-08 09:38:30 +02:00
Marc-André Bombeck 21a14ea679 docs(workouts): drop the version guess from the enrichment notes
The release these land in is not cut yet; naming one in a code comment
would be a number nobody verified.
2026-08-08 09:28:12 +02:00
Marc-André Bombeck 508d44633e fix(workouts): one series, one enrichment, when a batch names a workout twice
Two entries carrying the same external id and both a `samples` array
reached the enrichment arm one after the other, and both read the same
landed workout id back, so the response claimed two attaches where the
database had written one. The second entry now takes the duplicate arm:
a workout is claimed by the first entry of the batch that reaches it.
2026-08-08 09:26:24 +02:00
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 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 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 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 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 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 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 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 7a4ee19c68 feat(mood): add the MoodContext sub-row and its encrypted note
- migration 0311 creates mood_contexts, keyed by the entry, cascading from
  both the entry and the owner
- work, contacts, leisure and one notable event; every field nullable, and an
  entry with nothing filled in gets no row at all
- notes_encrypted registered in ENCRYPTED_COLUMNS and in the rotation script,
  so a key rotation cannot strand it
2026-08-08 06:51:42 +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 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 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 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 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 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 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 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 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 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 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 9a390aacea feat(mood): add the level-A dimension table and the A1 derivation map
- MOOD_DIMENSIONS carries the five self-state dimensions in capture order,
  each with its column, its label and anchor keys, its orientation and its
  0-10 bounds
- getA1ForMood maps the five-point label onto the pleasantness scale once,
  so the quick check-in stays a full-value capture path
- only stress is inverse-oriented, and a test pins that count
- the secret scan reads a dotted message key on a *Key field as a token;
  allowlist the dimension keys the way the existing message keys are
2026-08-08 00:36:09 +02:00