Commit Graph
3875 Commits
Author SHA1 Message Date
Marc-André Bombeck 6485aae787 fix(insights): surface mood-insights read failures instead of silent degradation
The mood-insights regions returned nothing on a failed read by documented
intent, degrading to the line chart. Enforce the honest-error standard
here too: on error the main region paints a QueryErrorRow with retry, once,
mirroring how the loading skeleton is scoped to that one region so the
three regions do not stack identical rows. Test watched the silent empty
first.
2026-08-09 04:15:12 +02:00
Marc-André Bombeck 1229d54d30 fix(insights): recoverable read failures on the ECG detail and list page
The ECG detail rendered a read failure as a muted, non-recoverable line;
the routed list page had no error branch at all, leaving the shell
heading over an empty body that read the same as loading. The detail now
paints a QueryErrorCard with retry (an alert is content, not muted meta),
and the page shows the same instead of an empty shell. The overview teaser
still unmounts silently, which is right for the overview. Tests watched
each fail first.
2026-08-09 04:13:46 +02:00
Marc-André Bombeck 22d5c01a68 fix(settings): surface AI settings-read failures instead of a default form
The three AI configuration reads each returned null on a non-OK response,
so a failed read painted the default provider form as if the account were
configured that way. Throw on a non-OK response so the query reports its
error, and render a QueryErrorCard with retry when any read fails. Test
watched the misleading form render first, then pass on the honest error.
2026-08-09 04:11:08 +02:00
Marc-André Bombeck 51c40c61d9 fix(dashboard): show a read failure on the Vorsorge tile, not an empty state
The tile already read the query's error flag but never branched on it, so
a failed reminder read fell through to "no upcoming checkup reminders" —
a missed preventive-care nudge hiding behind an honest-looking empty
state. Add a QueryErrorRow with retry before the empty check. Test
watched fail first against the fall-through.
2026-08-09 04:08:57 +02:00
Marc-André Bombeck 48e4882400 fix(settings): show read failures on the MCP connector lists, not empty states
The connected-assistants and connector-token queries had no error or
loading branch, so a failed read rendered "no connected assistants" or
"no active tokens" on this security surface. Gate both lists with
QueryErrorCard and retry, a skeleton while loading. Test watched fail
first against the old empty-state fall-through.
2026-08-09 04:07:10 +02:00
Marc-André Bombeck ab2c5cb2aa fix(settings): show a read failure on the API-token list, not an empty state
Same fall-through as the share-link list on another security surface: the
token query had no error or loading branch, so a failed read rendered
"no active tokens". Gate the list on the query state with QueryErrorCard
and retry, a skeleton while loading. Test watched fail first against the
old empty-state fall-through.
2026-08-09 04:05:35 +02:00
Marc-André Bombeck 53a463f11b fix(settings): show a read failure on the share-link list, not an empty state
The share-link query had no error or loading handling, so a failed read
collapsed to an empty list and rendered the "no active share links"
copy — on a clinical sharing surface a load failure was indistinguishable
from "you have shared nothing". Gate the list on the query state:
QueryErrorCard with retry on failure, a skeleton while loading, the lists
only once the data is in. The added test was watched fail first against
the old fall-through before this fix made it pass.
2026-08-09 04:03:09 +02:00
Marc-André Bombeck 6e3965146c fix(settings): honest address, greeting comma, and managed-record error classes
Three small copy corrections that share the locale bundles:
- Resolve the formal-address outliers on the owner sharing and report
  scopes to the informal register the rest of those surfaces use.
- Add the direct-address comma to the personalized dashboard greeting.
- Split the managed-record form error: a client-side validation failure
  no longer surfaces as a network error, and a failed save gets its own
  line.
2026-08-09 03:59:34 +02:00
Marc-André Bombeck 6d67e84cea fix(dashboard): localize the respiratory-rate unit label
The respiratory-rate tile hard-coded the English unit while the metric
sub-page already reads the localized string. Point the tile at the same
key so every locale shows its own unit.
2026-08-09 03:59:19 +02:00
Marc-André Bombeck 338e9bc818 fix(settings): render AI consent copy as foreground content
The central-codex and auto-read consent notes are safety copy the user
has to read before enabling a data-sharing path, but both rendered as
muted extra-small meta. Promote them to foreground text-sm and keep only
the decorative icon muted.
2026-08-09 03:59:19 +02:00
Marc-André Bombeck b46550d9a2 fix(settings): correct the MCP token scope note to match the write toggle
The note above the write toggle claimed tokens are read-only in every
case, which the toggle directly below it contradicts by minting a
read_write scope. Rewrite it to state the real model: read-only by
default, write opt-in per token, never admin. Resolve the pronoun that
read as a formal address, and promote both scope notes to foreground so
this security copy no longer renders as muted meta.
2026-08-09 03:54:23 +02:00
Marc-André Bombeck 8d5948ceb3 chore(release): v1.37.3 — the immunization record 2026-08-09 01:55:42 +02:00
Marc-André Bombeck 34cd41a359 fix(a11y): name the catalogue and practitioner combobox triggers
A role=combobox button is not named from its content, so the visible
placeholder text left both the vaccine-catalogue trigger and the shared
practitioner trigger without an accessible name, and the wrapping field label
pointed at an id the button did not carry. Name each from its field label. The
practitioner trigger is shared, so the visit form gains the same name.

Also settle CSS animations before the accessibility scan and poll the reminder
reads in the booster flow: the scan otherwise raced the capture sheet's fade
and measured a half-opacity blend as a contrast failure, and the flow read the
reminder list before the mint and the re-anchor had propagated. Both wait for
the settled state rather than relaxing any assertion.
2026-08-09 00:50:35 +02:00
Marc-André Bombeck e5072ba99e fix(reminders): bound a rolling reminder's re-anchor by the notify-hour slot's own day
A rolling reminder at the ten-year maximum interval lost its next due when
the satisfy instant fell in the late-UTC evening. The next-occurrence search
bounded the single deterministic rolling slot by the end of the anchor's UTC
day, but the applied notify-hour slot lands on the following UTC day whenever
the anchor sits in the evening (the local morning is the next calendar day in
a positive-offset zone). The anchor-day ceiling was then a day short and
clipped the very slot it meant to admit, so logging the next dose set nextDueAt
to null and the booster silently disappeared. Key the ceiling off the applied
slot instant instead; endsOn stays enforced inside expandRolling.
2026-08-09 00:21:39 +02:00
Marc-André Bombeck 4766828555 fix(reminders): keep a maximum-interval rolling reminder's next due after satisfy
A rolling reminder at the engine's ten-year maximum interval lost its next
due date the moment its cadence was satisfied. The rolling branch of
nextOccurrenceAfter bounded its search by the 10-year anti-spin hardCap, and
a satisfied reminder computes its next slot with after === lastSatisfiedAt,
so the single deterministic slot lands exactly after + 3650 days — equal to
the hardCap to the millisecond. The notify-hour slot then spilled a few hours
past the raw boundary and was clipped to nothing, leaving nextDueAt null.

The rolling slot is one deterministic instant, not an open-ended walk, so the
anti-spin cap must never gate it. Bound the rolling search by that slot's own
day instead; endsOn stays enforced inside expandRolling, so a course-window
reminder still self-expires.

This surfaced on a ten-year tetanus booster: logging the next dose silently
dropped the reminder instead of rolling it forward a decade.
2026-08-08 23:54:44 +02:00
Marc-André Bombeck 999206767f Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-2 2026-08-08 23:01:36 +02:00
Marc-André Bombeck a6a65719eb test(vaccinations): cover the immunization log end to end
Five Playwright flows over the built app, each asserting stable data-slot
attributes rather than viewport text: transcribe a catalogue pick with a lot
into its antigen group, a combination dose rendering under each of its three
component groups, the booster prompt minting a Vorsorge reminder that shows on
checkups and moves its due date when the next dose is logged, the module-off
redirect with the nav entry gone, and an axe pass over the list and the form.

The catalogue option gains a data-catalog-slug hook so a flow can pick an exact
antigen deterministically. The spec runs in a single project and serial, and
clears its own rows before each test, so a grouped verdict counts only what the
test wrote.
2026-08-08 23:00:29 +02:00
Marc-André Bombeck 958f3bc50a test(vaccinations): reconcile the structural guards with the new surfaces
- freeze the booster-mint and upload-suggestion routes into the sharing
  surface guard with their why sentences; delegable-write and mutation
  inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
  like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
  success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
  group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
  longer throws for a fully-selected report
2026-08-08 21:43:28 +02:00
Marc-André Bombeck 4db70d3eb1 feat(vaccinations): name the catalogue in six locales
- the ~35 antigen and combination display names, medical orthography per
  locale, umlauts intact end to end; disease and antigen names only, never a
  trade name
- the three clinical proper nouns whose written form is identical across
  locales (Hib, Hepatitis A/B in Spanish) are recorded as legitimate matches
2026-08-08 21:24:00 +02:00
Marc-André Bombeck 2da0159577 feat(vaccinations): add the immunization history to the doctor report
- IMMUNIZATIONS report leaf in the history group, gated both ways: the
  vaccinations module and the leaf selection each exclude independently
  (WR-7 seen red on the selection gate, reverted by content hash)
- collector loads the whole live history (reference data, not windowed, like
  allergies) with server-resolved series positions; the PDF renders a compact
  per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis
- added to the default template so a new report offers it; stored selections
  and frozen share links never widen
- leaf label and section copy in six locales
2026-08-08 21:21:12 +02:00
Marc-André Bombeck f6a4acf779 test(sharing): follow the invite form's new scope default and one-line copy
The access options render as a row with an info affordance rather than a
wrapping label, the scope question preselects nothing, and the write and
manage detail moved behind the (i). Point the consent-copy and form-reset
guards at the shipped bundle for the detail text and at the unset scope
default.
2026-08-08 21:20:57 +02:00
Marc-André Bombeck e0692efba8 fix(sharing): widen the grant db slice for the expired-row sweep
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
2026-08-08 21:14:23 +02:00
Marc-André Bombeck 9b58394724 feat(vaccinations): link documents to doses, with the upload suggestion
- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
2026-08-08 21:11:21 +02:00
Marc-André Bombeck ba4ec67812 fix(sharing): shorten the scope option copy to one line each
The write and manage levels each carried a paragraph of capability text
under their label, stacking into walls of text on the invite form. Cut
each to a single consent sentence inline and move the full enumeration
behind the info affordance at the option-heading height — what exactly
write may add, and the fence manage does not reach. The consent line
stays inline where it has to be read; the detail is one tap away for
anyone who wants the whole list. Six locales.
2026-08-08 21:09:52 +02:00
Marc-André Bombeck 97e75fd098 fix(sharing): stop preselecting a scope when inviting someone
The scope question defaulted to whole record, which reads as the form
nudging the choice. Preselect neither option: an untouched fieldset now
blocks the form with a quiet hint instead of falling through to a
whole-record grant. The wire contract is unchanged — scope-absent still
means the entire record on the server, so the unset state deliberately
does not omit scope, it blocks the client until the owner picks. The
empty-selection and manage floors stay fail-closed as before.
2026-08-08 21:05:10 +02:00
Marc-André Bombeck de83954a7f fix(sharing): let an expired grant be re-granted instead of colliding
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
2026-08-08 21:01:50 +02:00
Marc-André Bombeck b5ce44628a feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
  ordinary VORSORGE reminder keyed on the dose's primary antigen; the
  published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
  reminder rather than minting a second (WR-9 seen red on the reminder count,
  reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
  the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
2026-08-08 21:01:25 +02:00
Marc-André Bombeck ee561eaa23 fix(settings): move password reset to the end of the security section
Security now reads authenticator, then security keys, then passkeys,
then the password — the password card was mounted first. Keep it outside
the factor-status conditional so a stalled MFA read never takes it down;
it just renders last instead of first. A source-order guard pins the
sequence, since this class of ordering regression is easy to miss.
2026-08-08 20:57:40 +02:00
Marc-André Bombeck e344ebb781 fix(settings): drop the score pillar group headings for a flat list
The score-composition settings grouped its topics under domain headings
(Heart and metabolism, Activity, Sleep, Body shape, Wellbeing). Remove
them and list the individual topics one under another with hairline
dividers, the way the health profile lists its facts. Each row keeps its
domain data attribute, so the flattening preserves the registry order and
tests still address a pillar by its domain. The now-orphaned domain-label
map and its six-locale strings go with it.
2026-08-08 20:56:50 +02:00
Marc-André Bombeck 955ef6c6b6 fix(ui): keep the sheet scrollbar off the right-aligned labels
The desktop dialog body was the lone scroll port and carried no
horizontal padding of its own — the vertical scrollbar rendered flush
against its right edge, exactly where a slider's right-aligned high
anchor sits, so an overlay scrollbar painted over the last characters of
labels like the mood endpoints. Reclaim a 12 px gutter from the dialog's
own padding (-mr-3 pr-3): the scrollbar now sits beside the text, content
alignment unchanged. Universal to every ResponsiveSheet dialog.
2026-08-08 20:54:25 +02:00
Marc-André Bombeck 2e5061337b fix(ui): give date and time fields the standard focus ring
DateField and TimeField carried a divergent focus vocabulary — a hard
`ring-2 ring-offset-2` that painted a 4 px ring outside the field's
border box, so the highlight was visibly wider than the div and shifted
on focus. Pull both onto the Input vocabulary: a soft 3 px ring hugging
the box, no offset. Also match Input's `text-base md:text-sm`, so a
sub-16 px input no longer triggers iOS Safari's focus auto-zoom. Every
DateField and TimeField in the app inherits the fix.
2026-08-08 20:53:35 +02:00
Marc-André Bombeck 271e6f5db6 fix(mood): make the slider clear affordance an icon, not a text label
The clear-action button sat as muted text beside the value, so a set
slider read as one phrase — the number fused with the word next to it
(a value of 4 next to "leeren" showed as "4 leeren"). Replace it with
an icon-only X that carries the same label as its accessible name, so
the value readout stays the bare number in every locale at once.
2026-08-08 20:52:20 +02:00
Marc-André Bombeck f540298521 feat(vaccinations): render the catalogue's sourced schedule text
- compose rung-1 sentences from the seed's series length, booster interval and
  category over a handful of i18n templates, with the per-entry source as a
  verbatim footnote; nothing personal, no age or history conditioning
- surface it in the picker's chosen-entry detail and behind the antigen group
  header's info affordance
- schedule templates in six locales, plural-aware
2026-08-08 20:45:46 +02:00
Marc-André Bombeck ff72917833 feat(vaccinations): add the dose capture and edit form
- one sheet for create and edit, saving with a date and one identity arm
  (catalogue pick or free text); nothing else can block the save
- catalogue picker searches localised names and generic synonyms over the
  static antigen list, never overwriting the free-text arm
- optional dose/series, lot, site, practice combobox, visit suggestion and
  note, all reusing the encounter-form primitives
- write hooks fan out through the dose dependent-keys and the reminder reads
- form/site/sheet copy in six locales
2026-08-08 20:40:40 +02:00
Marc-André Bombeck 1d07204293 feat(vaccinations): group the log by component antigen with resolved series
- read the immunization log through the query-key factory, unwrapping the
  envelope; the series arrive resolved per component antigen from the DTO
- render one group per antigen, a combined dose appearing in each of its
  component groups with that component's own position; free-text and dead-slug
  rows fold under their verbatim name
- neutral cards, meta in muted, no due status on the page
- add the dose-list dependent-keys bundle, evicting the preventive-care root
  alongside the dose list so a satisfied booster never lingers on checkups
- series/lot/error copy in six locales
2026-08-08 20:32:05 +02:00
Marc-André Bombeck eefc151617 feat(vaccinations): add the immunization log route, nav entry and shared-record family
- register /vaccinations in the clinical nav spine, module-gated on
  vaccinations (default-on) and part of the shared record
- join /vaccinations to the profile shared-record route family so a scoped
  profile grant can present the page it is allowed to read
- add the page shell: authenticated, module-gated, with the standard
  module-off redirect the medications surface uses
- add the nav label and the surface title/subtitle/empty copy in six locales
2026-08-08 20:24:42 +02:00
Marc-André Bombeck 4f2dd51390 Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-1 2026-08-08 19:35:18 +02:00
Marc-André Bombeck 594898d350 Anchor the document visit-suggestion on a full instant
The suggestion anchored on the document's own date, which the DTO carries
as a date only. The suggest endpoint takes a full instant, so every
document asked the question as `2025-10-14` and got 422 back; the field
then rendered nothing and no document ever offered the visit its date
falls near. Lift the date to noon UTC before asking, the shape the two
lab moments already send.

The e2e that should have caught it never authenticated: the visits spec
carried no storage state, so its `page` redirected to login and its
`request` reads answered 401. It also asserts a global verdict — one
visit in the document's window — so clear the account's visits before
each test, run the file serially, and keep it to one project, since two
projects mutating the shared account would leave two candidates in the
window.

The accessibility mock lagged the same feature: the detail sheet reads
`encounterLinks` off the document and the mock omitted it, so the sheet
threw before it painted. Add the field the server always sends.
2026-08-08 18:46:55 +02:00
Marc-André Bombeck 08d3cd2c31 Split the ECG cap and one provenance entry off their label maps
The /insights overview crept a kilobyte over its ceiling: the ECG
teaser imported its overview cap from the classification module, which
dragged that module's label map and helpers into the eager page, and
the Health Score card pulled the whole metric-provenance map for its
one citation. The cap now lives in a value-only module and the single
provenance entry is split out, so the overview imports the bare number
and citation it renders. Both routes are back under budget. The
aggregate ceiling rises to hold the six-locale copy the new encounter,
mood-context, mood-prognosis and practitioner surfaces added, which the
whole-build sum counts independently and no single route downloads.
2026-08-08 17:56:40 +02:00
Marc-André Bombeck e1a4b7631c Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
2026-08-08 17:07:58 +02:00
Marc-André Bombeck 4c2dfa57b4 Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish 2026-08-08 16:17:40 +02:00
Marc-André Bombeck f28c6df861 Merge branch 'main' into feat/encounter-phase-2 2026-08-08 16:11:50 +02:00
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

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

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

The cap is a prop now. The teaser passes ECG_OVERVIEW_LIMIT; the page passes
nothing and lists every recording. The constant lives in the React-free
classification module so the overview page can pass it without pulling the
dynamically-loaded ECG component into its bundle.
2026-08-08 16:06:57 +02:00
Marc-André Bombeck ab40fe6a6e Merge branch 'main' into feat/vaccinations-phase-1 2026-08-08 15:51:57 +02:00
Marc-André Bombeck 0a543abd18 refactor(vaccinations): drop the dependent-keys bundle nothing consumes yet
The dead-code gate is right: a bundle with no caller is dead code. The
capture surface brings it back with the mutation that needs it, and the
key module says what that bundle will have to evict.
2026-08-08 15:51:54 +02:00
Marc-André Bombeck 0bf3f5c8d9 Merge remote-tracking branch 'origin/main' into feat/mood-prognosis 2026-08-08 15:41:13 +02:00
Marc-André Bombeck 75c43c74bc docs(mood): state the validation's real leak boundary, drop the unused weekday floor, correct two comments
Four honesty fixes with no behaviour change to the math.

The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.

The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.

Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.

And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.

The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
2026-08-08 15:30:38 +02:00
Marc-André Bombeck d58edc5deb fix(mood): floor the fit on complete rows, not only on rated days
The ladder gate counts days carrying a rating. But a day missing one of
the fit's selected features is dropped rather than imputed, so a
sparse-context account can clear thirty rated days and be fitted on far
fewer complete rows — a forecast "built from twenty days" past a floor
meant to say thirty. n beside the value and a wide band on a small fit
keep it from being dishonest, but the floor should still hold.

The number of complete rows the fit actually used now has to clear the
same threshold as the rated-day count; below it the account gets the
absence shape, and the read path then reports "not enough of a pattern
yet" rather than a value resting on a handful of days.

Watched red with a fixture of forty rated days carrying the driving
feature on only the twenty-five most recent — enough to screen the
feature in and to score the recent days, so the refusal is about the
complete-row count and nothing else. Before the floor it wrote n=25.
2026-08-08 15:30:20 +02:00
Marc-André Bombeck 9a47b4db49 fix(mood): refuse a forecast below the data floor on the read path, not only in the job
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.

The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.

The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
2026-08-08 15:30:00 +02:00