Commit Graph
9450 Commits
Author SHA1 Message Date
Marc-André Bombeck 503e69488a Merge branch 'fix/v139-review-client' into release/v1.39.0 2026-09-24 12:32:31 +02:00
Marc-André Bombeck e822154936 fix(cycle): give back a start that a mis-tapped earlier start folded in
A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.

Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
2026-09-24 12:30:39 +02:00
Marc-André Bombeck d38ca7737e fix(measurements): offer only measurement types whose module is on
The measurement form, and with it the add menu's Measurement entry,
listed sleep and glucose with those modules switched off. It now filters
its types through the surface map's type ownership, which a test holds
equal to the ownership the server gates measurement types on, and a
default or last-used type that is switched off falls back to the first
offered one.
2026-09-24 12:24:47 +02:00
Marc-André Bombeck f28153c832 fix(tz): refuse bare UTC offsets as a timezone
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through,
but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east
of UTC is negative), and the day-grain SQL aggregates would cut every day on
the wrong side of UTC.

isValidTimezone now accepts named zones only: whatever the runtime resolves
to a bare offset is refused, while named zones, their aliases, UTC and the
Etc/GMT family stay accepted. Every write that already validated through it
refuses offsets now, and the screener and mood linked-context request schemas
validate their zone too. A value stored before this change can still sit in
the database, so the SQL call sites that read the stored zone directly (the
dashboard summary, achievements, the doctor report, the tiered AI context)
fall back rather than splice it, as the resolver and the day aggregates
already did.
2026-09-24 12:23:00 +02:00
Marc-André Bombeck a15a148667 fix(insights): hold the scores strip until the cycle ring is decided
For a cycle-tracking account the strip painted Strain first and swapped
it for the cycle ring once the calendar read landed, moving the row
under the reader. The ring's decision now reports while it is pending,
and the strip keeps its skeleton until then.
2026-09-24 12:22:52 +02:00
Marc-André Bombeck e49997bf97 fix(settings): offer Coach links only while the Coach can open
Settings → Coach's sources pointer and the About-me follow-up questions
linked to /coach whatever the Coach's state. Without a provider or
consent the Coach page sends the visitor straight back to Insights, so
the link only bounced. Both now follow the Coach capability; the
questions themselves stay readable.
2026-09-24 12:21:21 +02:00
Marc-André Bombeck fbe2cd5833 fix(settings): say precisely what withdrawing AI consent stops and deletes
The confirmation said the AI features stop straight away, and the
withdrawn line that no health data goes to an AI provider. The Coach and
Insights need the receipt only when they use the AI access the server
provides, so on an own key or a local model they keep working, and the
model-written notes are kept while another consent still covers
Insights. Both strings now say so in every locale, and a guard pins the
copy next to the resolver rule it describes.
2026-09-24 12:20:14 +02:00
Marc-André Bombeck f700a7c0f1 fix(coach): let the capability table decide the about-me question consent
The aboutMeQuestions capability admits an ai_insights_only receipt, but the
question generator ran its own gate that demanded a Coach receipt, so a
person whose capability read available still got the fallback questions on
an operator-held chain.

The generator now runs the shared wire re-check (aiEgressRefusal) for the
aboutMeQuestions key, so the receipt kinds and the rule for when one is
needed come from the capability table alone.
2026-09-24 12:19:11 +02:00
Marc-André Bombeck 8d60690b4a fix(insights): give the setup hint's dismiss button a 44 px target on phones 2026-09-24 12:18:24 +02:00
Marc-André Bombeck 091e4b84cb fix(backup): fold a pre-0343 Coach availability key on instance restore
Migration 0343 moved the operator's Coach answer from module availability
into the Coach switch and folded every live row. Restoring the instance
settings from an older file wrote the dead coach key back into module
availability and left the Coach switch on, so a Coach the operator had off
came back.

The restore now applies the same fold: coach false in the file's module
availability switches the Coach off, and the key is dropped either way.
2026-09-24 12:18:00 +02:00
Marc-André Bombeck a2282a498c fix(encounters): give the visit form's link pickers their own query keys
The visit form's lab and episode pickers fetched with their own page
sizes under the same keys as the Labs list and the illness pages, which
fetch with others. Whichever read landed first served the other: the
picker could see 50 episodes instead of 100, or the Labs list a 200-row
page as the whole list. The picker reads now have keys of their own,
under the lab and illness roots so writes still evict them.
2026-09-24 12:17:56 +02:00
Marc-André Bombeck aeb8930ce1 test(e2e): assert slots the AI-off overview can actually paint
Three assertions in the AI-optional overview spec named slots that no
longer exist anywhere in the tree, so they held whatever the page did.
They now name the error primitives the overview paints on a refused read
and the Coach hand-offs that paint only while the Coach is available, and
the no-provider journey checks that /coach goes back to the overview.
2026-09-24 12:16:57 +02:00
Marc-André Bombeck 2c1e0f5bca fix(ai): drop model text whose consent was withdrawn while the call was in flight
A consent withdrawal purges the stored status notes, briefing, reaction
lines and workout paragraphs in its own transaction, but a provider call
that was already in flight still persisted its reply afterwards, bringing
the text back under a consent that no longer existed.

The consent rule now runs once more after the reply arrives, against the
provider that actually answered and with a fresh receipt read: in
runStatusCompletion (status notes, workout paragraphs, narratives, derived
assessments), before the briefing commit, and before the reaction-line
commit. A refused reply is dropped; the spent tokens stay on the ledger.
2026-09-24 12:15:56 +02:00
Marc-André Bombeck 15cce33a28 fix(documents): keep the link pickers from deleting a link made elsewhere
A dose or a visit can link documents from its own form, and a
document's sheet can link doses and visits; both ends write the same
table, and the sheet writes the whole set on every tap. Dose and visit
writes left the document reads alone, and the sheet seeded its pickers
from a detail cached for five minutes, so the first tap there after
linking from the dose's form wrote a set without the new link and
removed it.

Dose and visit writes now evict the document root, the sheet reads the
document again on every open, and its pickers wait for that read before
they offer a change.
2026-09-24 12:15:36 +02:00
Marc-André Bombeck 28d491174e fix(onboarding): keep the tour from bouncing between the Coach and Insights
With no provider set up, a fresh self-host's default, the Coach module
is on but /coach redirects to /insights because the Coach is not
available. The tour read the raw module map, kept its Coach stop, pushed
/coach, was sent back to /insights and pushed /coach again on the
pathname change: a loop.

The tour now reads the same capability-folded map as the navigation, so
the Coach stop is not in the list. And a stop whose page redirects is
dropped after one navigation, in the direction the person was going,
instead of being pushed again.
2026-09-24 12:13:15 +02:00
Marc-André Bombeck bd58571d46 fix(coach): hold reminders while the Coach is unavailable
The daily sweep and the measurement-ingest hook surfaced due reminders into
a Coach conversation whatever the record's coach capability said, and the nag
cap then dismissed them after three ticks although no badge ever showed. The
app-open path already waited on the capability.

surfaceCoachReminders now holds every row while the coach capability is
unavailable (the same answer that keeps the badge dark), and the nag cap
neither charges nor dismisses a held user's surfaced rows. They surface on
the first tick after the Coach is back. The integration fixture gives its
users a provider and consent, and a held case covers the sweep, the nag cap
and the ingest hook.
2026-09-24 12:11:21 +02:00
Marc-André Bombeck 676bb2eff7 fix(ai): refresh the account's AI answer after every change to its inputs
Every AI surface reads its capability from the ai block on
/api/auth/me, but setting up or removing a provider, signing in to or out
of Codex, saving the fallback chain, the central-Codex opt-in, the
local-OCR switch, a minted consent receipt, the admin's server key and
central Codex, and a guardian's save inside a managed record evicted only
their own reads. The surfaces stayed on the old answer until a reload.

All of these writes, and the ones that already refreshed the payload
(consent, modules, Hide Coach, the operator's switches), now evict one
bundle, aiInputDependentKeys. A structural test holds every client write
to those endpoints to it.
2026-09-24 12:11:14 +02:00
Marc-André Bombeck 06251d7219 fix(ai): read AI switches, modules and consent afresh inside background jobs
A background job opens one wide event for its whole run, and the per-request
memo cached the operator switches, module availability, module preferences,
provider presence and consent kinds on it. The nightly status passes loop
over every candidate inside that one event, so a switch turned off mid-run
did not stop the remaining users, and the wire re-check read the same cached
answer.

memoizePerRequest takes a freshInBackground option; every input that decides
whether AI work may run sets it, so a background caller reads them on every
call while a request still reads them once.
2026-09-24 12:08:39 +02:00
Marc-André Bombeck 03ecb5bd52 fix(coach): keep stored plans and conversations reachable with the Coach off
The page gate matched /coach/plans and /coach/conversations to the Coach
module by the nav prefix, so with the Coach switched off by the operator
or hidden by the person both pages were replaced by the module notice and
never reached their read-only mode. Both are now stored-record pages the
gate leaves alone.

With the Coach off nothing in the navigation led there either. Settings
now carries a plans card, shown when plans exist, and the conversations
card links to the full list.
2026-09-24 12:05:52 +02:00
Marc-André Bombeck 4ae231d757 Merge branch 'feat/coach-reminders-readable' into release/v1.39.0 2026-09-24 11:58:46 +02:00
Marc-André Bombeck 6e45acfbc6 feat(coach): keep stored reminders readable and erasable with the Coach unavailable
Reminders are the person's own record, like the stored facts,
conversations and plans. The list and delete routes no longer ask the
Coach module; creating, confirming, resolving or rescheduling one stays
Coach use and is refused by POST and PATCH as before.

Settings -> Coach now always mounts the reminders card (shown with Hide
Coach on only when reminders exist), and Settings -> AI lists them through
the stored-memory block while Settings -> Coach is out of reach. With the
Coach unavailable the card is read-only: every reminder can be read and
deleted, while keep / done / dismiss are not offered.
2026-09-24 11:57:42 +02:00
Marc-André Bombeck 5e5d33ecf8 feat(coach): keep stored plans readable and erasable with the Coach unavailable
Plans are the person's own record, like the stored facts and
conversations. The list and delete routes no longer ask the Coach
module, and the plans page turns read-only instead of redirecting to
Insights: every plan can be read and removed, while confirming or moving
one stays Coach use and is refused by the PATCH route as before.
2026-09-24 11:48:15 +02:00
Marc-André Bombeck e0ece41e21 Merge branch 'feat/ai-web-surfaces' into release/v1.39.0 2026-09-24 11:37:10 +02:00
Marc-André Bombeck b7830c3a72 test(e2e): cover Insights and the Coach memory without AI
Adds the AI-optional journeys: the overview with AI switched off by the
operator and with no provider (no AI requests, no refusals, one setup
hint, remembered dismissal), and on an account of its own, withdrawing AI
consent deletes the stored briefing, and a hidden Coach's conversation is
read and deleted from Settings, AI.

Specs that drive AI surfaces against mocked routes now serve an available
capability map through /api/auth/me, since the web no longer assumes AI
is on until told otherwise.
2026-09-24 11:36:30 +02:00
Marc-André Bombeck e72441743f fix(settings): never report a failed consent read as a withdrawal
The AI consent card turned any failed read of the receipt (a rate limit, a
network drop) into "Withdrawn" and offered a grant. It now says the read
failed, with a retry. The Coach history note is one sentence.
2026-09-24 11:36:29 +02:00
Marc-André Bombeck a6c65bd37e fix(insights): give the cycle ring a score cell only when it has a dial
The scores strip reserved a cell for the cycle ring, and dropped Strain to
make room, whenever the cycle module was on. Without an active cycle the
ring renders nothing, so the row showed a gap where Strain used to be. The
overview now asks the same calendar read whether there is a dial today.
2026-09-24 11:10:48 +02:00
Marc-André Bombeck 5f6a0e42b4 refactor(web): retire the feature-flag reader for the capability map
The web no longer reads GET /api/feature-flags: useFeatureFlags and its
query key are gone, and use-ai-capability gains the two readers the Coach
pages and the operator notice need. A structural guard fails when web code
reads the retired projection, and when a module requests an AI route
without being registered with the capability that gates it.

Adds the copy for every new state in all seven locales and drops the
per-card no-provider strings.
2026-09-24 11:05:59 +02:00
Marc-André Bombeck 3d85445e50 feat(settings): explain operator switches and keep the Coach memory reachable
Settings, AI says which AI features the server's operator switched off and
shows the one setup hint while no provider is configured. The consent
withdrawal says what it deletes and what stays, and a document-reading
consent on file gets its own withdrawal. Regenerate follows the briefing
capability and the connection test names an operator refusal.

The Coach memory (facts and stored conversations) is never gated on the
Coach: Settings, Coach always shows it, and Settings, AI shows it while
the Coach page is out of reach, whenever rows exist.

Onboarding offers AI only where it can be set up here: the done-screen
panel and the getting-started row go away while the operator has AI off
or inside somebody else's record.
2026-09-24 11:05:54 +02:00
Marc-André Bombeck 4449b34ac7 feat(documents): gate document, lab and medication reading on their capabilities
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.

Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
2026-09-24 11:05:49 +02:00
Marc-André Bombeck 48d97ef70d feat(insights): render AI parts from the capability map, never as errors
The daily briefing, the hero's briefing paragraph, the trend sentences and
the status notes follow the briefing and statusText capabilities: hidden
while unavailable, never an error card or a per-card "connect a provider"
state. The advisor and status reads only fire while their capability is
available; a 403 settles as an outcome instead of throwing.

A failed comprehensive read no longer replaces the overview, and every
overview section is fenced so a section that throws becomes one error row.
The period review always renders its composed narrative. Cycle, breathing
and lab-change sections go through the surface map. One calm setup hint
appears on the overview while no provider is configured and one could be.

Signals, rhythm events and the mood patterns card say a failed read with a
retry, the score anatomy no longer calls a failed read insufficient data,
the catalog marks only the rows whose read failed, and workouts are not
probed while the module is off.
2026-09-24 11:05:44 +02:00
Marc-André Bombeck 61d912627d feat(coach): follow the coach capability at every entry point
The floating button, the drawer, the launch pills, the nav entry and the
Coach pages read the coach capability from /api/auth/me instead of
combining the operator switch with Hide Coach on their own. A missing
provider or consent now hides the Coach too, instead of opening one that
can only answer with an error.

Stored conversations stay reachable: while the Coach is unavailable the
conversation list turns read-only, opens a transcript in place and keeps
delete. Refusals before the stream opens are read by meta.errorCode and
each code has its own copy.
2026-09-24 11:05:38 +02:00
Marc-André Bombeck 114ff4b4eb test(sharing): read loadDocument as a headerless navigation sink
The connect and sign-in hand-offs moved from window.location writes onto
the loadDocument helper, and the guard stopped seeing them. Its reference
floor caught the drop. Watched red with a fenced thumbnail route handed to
loadDocument.
2026-09-24 10:57:45 +02:00
Marc-André Bombeck f893dae004 Merge branch 'test/v139-flag-mocks' into release/v1.39.0 2026-09-24 10:52:30 +02:00
Marc-André Bombeck c229829663 test(health-score): answer the provider probe in the configured-flag checks
The provider mock lacked `probeProviderChain`, so the capability loader
threw on every wire and read `check_failed`. It now answers "no
provider" like the rest of the mock, and the derived payload pins
`no_provider` so a starved loader fails the file.
2026-09-24 10:52:04 +02:00
Marc-André Bombeck 00f6eb6dcb test(measurements): mock the status re-warm where the mutation tail imports it
The bulk-delete, restore and by-external-ids suites mocked
`invalidateStatusInsightsForTypes` on the comprehensive-generate barrel,
while the shared post-mutation tail imports it from status-invalidation.
The mock was never called, the real re-warm ran the capability loader
against a stub database, and the "no invalidate" assertion could not
fail. The mock now sits on the imported module; the positive cases
assert the re-warm with the distinct types, the zero-row cases assert
its absence.
2026-09-24 10:52:04 +02:00
Marc-André Bombeck 863d32af5a test(ai): refuse feature-flags mocks that leave the loader's reader out
A structural check over src/ and tests/: every `vi.mock` / `vi.doMock`
of `@/lib/feature-flags` either uses the shared switches factory or
names `loadAssistantSwitches`. The matcher is whitespace-tolerant,
asserts a non-zero match count, and checks itself against planted
one-line, split, automock and `import()` forms.
2026-09-24 10:52:04 +02:00
Marc-André Bombeck 2c4dec08f1 test(ai): answer both switch readers from one source in flag mocks
Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone.
The capability loader reads `loadAssistantSwitches`, so wherever it ran
it threw and every capability resolved to `check_failed`. A shared
factory now points both readers at the function the test drives.

Only the config-change chain reached the loader. Its fixture means AI
switched off by the operator; it now supplies the loader's other inputs
and asserts `operator_disabled` on the digest, so a starved loader turns
it red instead of passing on the failure path.
2026-09-24 10:52:03 +02:00
Marc-André Bombeck 48bfc1f987 refactor(navigation): name the full-document loads the client router cannot serve
Nine places assigned window.location.href directly: OAuth connects and
sign-in hand-offs that land on API routes answering with a redirect, and
the reload after account deletion. The newer Next lint rule reads each as
an in-app page change. They now go through one helper that says why a
document load is the right tool there.
2026-09-24 10:37:21 +02:00
Marc-André Bombeck c2005fd8f1 test(sharing): read the real assistant switches in the provider-origin checks
The file stubbed the old switch reader only, so the capability loader
failed and every capability resolved closed. The owner's positive control
then enqueued nothing, and the delegate's negative passed without a
provider on the record, proving nothing. The record and the owner now
carry a provider and consent, so the delegate case refuses for the
reason it names.
2026-09-24 10:32:41 +02:00
Marc-André Bombeck 9c7ddc13b3 Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
2026-09-24 10:26:56 +02:00
Marc-André Bombeck 3662d5139e chore(ai): drop the switch type import the retired gate left behind 2026-09-24 10:25:45 +02:00
Marc-André Bombeck d421aca7a1 Merge branch 'feat/module-surfaces' into release/v1.39.0 2026-09-24 10:23:25 +02:00
Marc-André Bombeck d8dc6ca8a7 fix(settings): answer a switched-off layout subpage with the module notice
/settings/layout/<module> is a URL a person can land on, so with its
module off it now shows the module notice in place, like every module
page, instead of bouncing to the Layout hub without saying why.
2026-09-24 10:18:04 +02:00
Marc-André Bombeck 5bd19573ea fix(insights): pass the record's module map to every correlation scan
The Coach correlations tool, the per-metric card and the period
narrative scanned every channel, so a switched-off module still fed
their statistics and the text a model was grounded on. The matrix's
modules option is now required, each caller resolves the record's map,
and the Coach skips the lab pass with labs off. A registry guard holds
every caller to resolving the map itself.

The mask's doc comment now states what it does to the false discovery
correction: fewer channels is a smaller family, so q-values of the
remaining pairs move and a borderline pair can cross the threshold.
2026-09-24 10:17:14 +02:00
Marc-André Bombeck 2143445da2 feat(ai): show stored model text inside a shared record as the owner would
Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.

The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.

Refs #975
2026-09-24 10:17:00 +02:00
Marc-André Bombeck 7df260d62e fix(daily): send the morning briefing push with AI analysis off
The push refused outright while the `insights` module was off, which
now means only "AI analysis". The digest it reads already drops the
model-written lead and top signal in that case, so the push goes out
with the deterministic line (the score, or the day's items) and a
digest with nothing to say is still skipped. The `module-off` outcome
and its counter are gone.

Refs #975
2026-09-24 10:17:00 +02:00
Marc-André Bombeck c509afeb2e Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
2026-09-24 10:16:53 +02:00
Marc-André Bombeck ab0d1b4b71 Merge branch 'feat/module-surfaces' into release/v1.39.0 2026-09-24 10:10:11 +02:00
Marc-André Bombeck 1f7a52cd7a test(nav): name the surface map in the cycle entry's comment 2026-09-24 10:09:35 +02:00
Marc-André Bombeck 8eff500e9d test(e2e): prove a switched-off module leaves every surface it owns
A journey on its own account switches mood and medications on, proves
the Mood nav entry, the Mood and Medications pills, the mood trends slot
and both add-menu entries are there, switches both off through the
module route and proves each is gone, the trends row fills from pulse,
/insights/mood shows the module notice at the same URL, and no chart on
the way paints its error state.
2026-09-24 10:06:14 +02:00