Commit Graph
966 Commits
Author SHA1 Message Date
Marc-André Bombeck 55a32ff7d1 feat(documents): add a document upload token for imports from other systems
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.

Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.

The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.

Refs #1038, #938
2026-09-25 21:48:04 +02:00
Marc-André Bombeck a46d0b41c8 chore(release): v1.39.1 — large accounts, restores in the background, and medications kept as a record 2026-09-25 13:12:34 +02:00
Marc-André Bombeck 1e040605be feat(medications): accept units per dose like 1.5 and let the editor enter them
A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.

The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.

The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.

Refs #1034
2026-09-25 09:58:55 +02:00
Marc-André Bombeck e03cf7c2c8 Merge branch 'feat/medication-record-only' into release/v1.39.1 2026-09-25 09:37:27 +02:00
Marc-André Bombeck d4c6e0fab2 docs(api): describe how a record-only schedule is kept on update 2026-09-25 09:25:40 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck d7bc873691 fix(backup): answer an upload too large to store with 413
An uploaded file whose encrypted copy passes the size one stored backup may
have on this host (a fifth of the heap limit) stopped the store with
BackupBlobTooLargeError, which the route let through as a 500 and reported
to the error tracker. It is now a 413 with the numbers and the remedy in the
message and meta.errorCode backup.upload.too_large, and the refusal is
audited like the others. The store rolls back, so nothing is kept.
2026-09-25 08:51:00 +02:00
Marc-André Bombeck 3abbad048e fix(backup): never run a restore again after its data committed
A restore commits its transaction and then rebuilds the chart tiers. A
worker that stopped during that rebuild left the job running with a stale
heartbeat, and the boot sweep and the admission check treated it like any
interrupted job: the sweep queued it again, so the second run deleted and
re-wrote the account and lost whatever was written in between, and a second
interruption closed it with "rolled back, nothing was changed", which was
false.

The job now records `committedAt` (added to migration 0347, which has not
shipped) and awaits that write before the rebuild starts. The sweep and the
admission check close a committed job as `failed_after_commit`, never queue
it again, and evict the account's caches. The rebuild itself is left to the
nightly and boot passes that already rebuild missing tiers.

The module comment now says what an interrupted restore does (restarted
once, then failed) and what happens to other writes while a restore runs:
new rows are written at once, updates to rows the restore already deleted
wait on their lock until it commits or rolls back.
2026-09-25 08:49:45 +02:00
Marc-André Bombeck c327ae81d5 Merge branch 'feat/procedure-history' into release/v1.39.1 2026-09-25 08:23:34 +02:00
Marc-André Bombeck ad0d50fa28 feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 08:11:37 +02:00
Marc-André Bombeck 934131ee3e Merge branch 'feat/restore-background-job' into release/v1.39.1 2026-09-25 08:10:06 +02:00
Marc-André Bombeck d0e321c54a Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/measurements.ts
2026-09-25 08:04:30 +02:00
Marc-André Bombeck f443ab6615 feat(measurements): accept sleep stages by name on POST /api/measurements
A bridge such as Tasker or Home Assistant could only send sleep stages
through the batch route, in HealthKit's terms: the category identifier,
a stage codepoint from 0 to 5, and a value it had to work out in
minutes. It can now post one segment to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`,
`CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or
`measuredAt` (the end) + `value` (minutes).

The row is the one the batch route writes for the same segment, and the
dedupe is the batch route's: the opposite-source same-reading check,
then one reconcile over both unique identities. Without an `externalId`
the server derives one from the stage and both instants, so a re-post
answers 200 `duplicate`, a corrected start answers 200 `updated`, and a
new segment answers 201 `inserted`. The narrow `measurements:write`
token is accepted and still writes only MANUAL rows. Every other body
on this route behaves as before.

Requested in #972.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck be112df972 fix(profile): report the time zone the server uses on /api/user/profile
`/api/auth/me` has reported the resolved zone, the stored one or the
instance default when the stored value is unusable, while
`/api/user/profile` returned the raw column. A client reading the
profile could cut days differently from the server, and sending the
value back in a PATCH was refused.

Both reads, and the profile PATCH answer, now go through one
`resolveStoredTimezone`. The field stays a string and always holds a
valid IANA name. The web account editor already reads the resolved
zone from `/api/auth/me` and saves through the validating timezone
route, so it shows the zone in use and can only write a valid one.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck a7682a9fed fix(measurements): name the batch validation refusal and the band an entry broke
The validation 422 from `POST /api/measurements/batch` now carries
`meta.errorCode: "measurement.batch.invalid"`, beside the existing
`too_large` and `source_not_permitted` codes, so a client can file the
rows as permanently refused instead of holding its anchor on a guess.

A `value_out_of_range` entry now also carries `convertedValue`, the
reading after conversion into the stored unit, and `range`
(`min`, `max`, `unit`), the band it was checked against. Both appear
only on that skip. A fraction sent where a percent was expected is now
visible in the response instead of looking like a sensor glitch.

Status codes, the envelope and every existing field are unchanged.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 47e9f7e81c feat(backup): run a restore as a background job with progress
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.

The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.

What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.

- One restore per account: a partial unique index admits one queued or
  running job, and a second request gets 409 `backup.restore.active` naming
  the running job. A per-account advisory lock inside the transaction holds
  even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
  limit would run past three quarters of it is refused before anything is
  deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
  queues a job whose worker stopped again from the start, at most twice,
  then fails it as interrupted. The admission check releases an abandoned
  job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
  rest. It stays whatever the outcome, so a retry needs no new upload. The
  job records a digest of the copy and refuses one that was replaced while
  it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
  after, and after a reload it finds the job again from the server. A poll
  that fails on the network is never shown as a failed restore. A web
  process separate from the worker evicts the account's caches when it
  first reports the restore finished. The success line goes through the
  outcome module, so an empty file is not drawn as a tick.

The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
2026-09-25 07:39:06 +02:00
Marc-André Bombeck ee23409b77 Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1 2026-09-25 07:38:34 +02:00
Marc-André Bombeck 702a13873d Merge branch 'main' into release/v1.39.1 2026-09-25 07:21:34 +02:00
Marc-André Bombeck adec5bd045 fix(cycle): carry every logged day entry on the calendar read
The calendar grid carried flow, symptoms, BBT, the ovulation test and the
mucus reading, and dropped the rest of the day log: intercourse, spotting,
the pregnancy and progesterone tests, contraception and whether a note
exists. The published contract also promised the three cervix signs while
the route never sent them.

The intent fields resolve through the same reader the day-log read uses,
so an encrypted envelope reads the same on both, and the grid is served
under the same cycle grant as the day log. Additive fields; the contract
test pins the wire keys to the published schema.
2026-09-25 07:02:14 +02:00
Antiheld86 9fc1413181 docs(measurements): label EXTERNAL and update the guide and contract
The measurements list already badges any row whose source is not MANUAL and
already builds its source filter from the shared enum, so both pick the new
value up on their own. What it needs is a name: `EXTERNAL` reads as a leaked
enum constant, and it is the only new value a user is expected to operate,
since it is an option in the filter and a badge on every bridged row.

Both label catalogues gain an entry. They are keyed by the enum, so a new
source without a label is a type error and the coverage guard fails the
suite — which is exactly what happened here, and is the reason this carries
a settings label too even though EXTERNAL sits on no priority ladder and so
never renders there.

The settings card's scope note and the Home Assistant guide both claimed
readings arrive "as if you had entered them by hand", which is no longer
true. Both now say the rows are marked as coming from an external device and
are still yours to correct, and the guide spells out that the payload must
not name a source at all.

The changelog entries go under Unreleased as Changed: v1.38.1 shipped the
token, so these describe a change to something people already have. They say
what that means in practice — rows written before this keep the old label
and cannot be sorted out afterwards, a batch re-push can duplicate because
the dedup key now carries the source, and an automation sending an explicit
`source` field has to drop it.
2026-09-25 06:55:17 +02:00
Marc-André Bombeck eafef5adeb fix(medications): keep a recorded miss from before the medication's creation
The history view treated every auto-miss anchored before the medication's
creation as the today projector's placeholder and dropped it. That is true
only on the creation day. History that arrives from before the creation, a
restore of an older file for one, can carry real misses, and those vanished
from the history and the rates.

The creation stays the boundary of expected doses: an earlier one, the
first recorded dose or the course start date, would read every day between
it and the creation that no row names as missed, and an import carries
takes and skips only, so those misses would be invented. Before it, every
recorded take, skip and miss claims its slot. Only rows that record nothing
are dropped: a pending row, and an auto-miss within the day before the
creation, which is where the projector's placeholders sit.

Refs #1028
2026-09-24 19:53:42 +02:00
Marc-André Bombeck fe7cccfd46 fix(medications): attribute doses to slots from before the medication existed
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.

The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.

The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.

Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.

Refs #1028
2026-09-24 19:40:27 +02:00
Marc-André Bombeck ecfa837dea chore(release): v1.39.0 — AI becomes optional, and a switched-off module leaves the whole app 2026-09-24 13:30:44 +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 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 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 e89f41710a Merge branch 'feat/v139-targets-and-links' into release/v1.39.0 2026-09-24 09:53:41 +02:00
Marc-André Bombeck 15d6251ad2 feat(vaccinations): show the planned booster's renewal state on the log
Whether a vaccination is still current was known only on the checkups
page. The vaccination log now shows it on each antigen's heading:
the booster the person planned when they logged a dose, marked as
overdue, due soon (within 30 days) or scheduled, tinted only in the
first two cases.

The state is resolved on the server from the person's own confirmed
booster reminder and published on the list response as renewals, so
the web page and the native client read the same answer. Nothing is
worked out from age, history or the catalogue: an antigen without a
planned booster shows no state. Renewals are null for a grant that does
not reach the measurements section the reminders live in.

Refs #1005
2026-09-24 09:44:51 +02:00
Marc-André Bombeck b480ac4543 feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.

The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.

Refs #1024, #1005
2026-09-24 09:39:27 +02:00
Marc-André Bombeck 1f6a13e2f7 feat(ai): answer the status notes with a null note when AI is unavailable
The status family (the five metric cards, medication compliance, the
generic metric status and the biomarker assessment) asked the retired
switch and the `insights` module and refused with 403. The note is model
text and the card around it is data, so they are mixed reads now: while
the `statusText` capability is available the note is served and warmed
as before, with `ai` beside it; otherwise the answer is 200 with the
note null, `preparing: false`, `hasProvider` as provider presence only
and `ai` naming the reason, and neither the cache nor the warm queue is
touched. The generic metric status still reports `insufficient` from the
data. The mood card keeps its mood module gate.

Kept apart so it can be held back while the shipped iPhone app's
rendering of a 200 without a note is confirmed.

Refs #975
2026-09-24 09:30:23 +02:00
Marc-André Bombeck c9534b343e Merge branch 'feat/ai-jobs-stored-text' into release/v1.39.0 2026-09-24 09:29:54 +02:00
Marc-André Bombeck ce72fe226e refactor(ai): re-check every chokepoint through the one egress helper
The status chokepoint, the comprehensive briefing, reaction lines and
AI-composed nudges now ask `aiEgressRefusal` about exactly the chain they
are about to send to, instead of each pairing the operator-key check
with a surface receipt lookup of its own. The consent kinds come from the
capability table, so the wire and the published capability answer from
the same rule.

The document summary, its catch-up pass and the index backfill stop on a
closed switch or module before anything is picked, and leave "no
provider" and "consent needed" to the document pick, which re-checks the
wire for the provider it chose. A closed switch still only heals PENDING
to NONE; a withheld pick records UNAVAILABLE. The per-pick document
consent helper is removed, since nothing calls it any more.

The auto-read toggle mints the shared `AI_EXTRACTION_CONSENT_KIND`, and
the web grant helper goes back to minting the master grant only.

Refs #975
2026-09-24 09:29:00 +02:00
Marc-André Bombeck 1273a76a63 feat(weight): judge the weight trend against the stored target
The weight tile's arrow, 7-day delta and comparison caption were wired
as "up is bad", so a person below their own target who was gaining saw
every step toward it coloured as a setback. Movement is now judged
against the target they entered: below the band a gain is progress,
above it a loss is, and inside it holding steady is, with a move either
way shown neutral rather than orange. Without a stored target the tile
reads exactly as before; the height-derived band is not treated as a
target because nobody chose it.

The direction is resolved once on the server and published on the
dashboard snapshot as tiles.weightTrend, so the web tile and the native
client colour the same reading the same way. The personal-record
direction reads the same placement helper. The feature payload behind
the briefing, the overview analysis and the Coach now carries the
target band and which way is progress, so a narrative no longer calls a
gain a regression when gaining is the goal. The insights vitals grid
drops weight from its fixed "up is bad" set.

Refs #1006
2026-09-24 09:22:50 +02:00
Marc-André Bombeck 3309ac177c feat(ai): serve Insights and Coach data whatever AI says
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.

Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.

`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.

An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.

Refs #975
2026-09-24 09:19:36 +02:00
Marc-André Bombeck 0c3195fd41 Merge branch 'feat/ai-extraction-routes' into release/v1.39.0 2026-09-24 09:05:54 +02:00
Marc-André Bombeck 005f645b8b docs(api): describe the extraction capability refusals
The document, labs-scan, medication-extraction, fenced-chat, attachment,
connection-test and auto-read operations document the capability they
answer under, the refusal envelope and codes, and the consent rule. The
two capability probes publish the capability beside the provider answer.
2026-09-24 08:45:06 +02:00
Marc-André Bombeck c3ec437cad Merge branch 'fix/v139-server-bugs' into release/v1.39.0 2026-09-24 08:21:55 +02:00
Marc-André Bombeck 352de95854 Merge branch 'fix/v139-ui-bugs' into release/v1.39.0 2026-09-24 08:12:51 +02:00
Marc-André Bombeck 3170a622d1 fix(medications): report the dose-history window doses were read over
The dose-history response reported the slot window: `from` clamped
to the medication's creation, `to` the request's end. Recorded doses
are now read from the requested start and, for a dose recorded ahead
of its slot, up to that slot, so the response reported a narrower
window than the rows it carried.

`from` and `to` now report the window recorded doses were read over.
Both fields keep their shape; the OpenAPI descriptions say what they
mean.

Refs #1028
2026-09-24 08:12:23 +02:00
Marc-André Bombeck 7d25bb7352 fix(cycle): log past dates as themselves, in any order (#1004)
The log sheet took its "Day N" header and its "period ended" button
from the verdict, which is the answer about today. A January date opened
in September read "Day 2", and a back-dated period could only be closed
on dates where today happened to be a bleeding day, so the button came
and went as unrelated months were filled in.

The calendar read now carries, per date, the cycle day of the logged
cycle that date belongs to (null before the first start, after today, or
past the verdict's ceiling for an open cycle) and whether a one-tap
period end can land there. The sheet reads both off the grid day for the
date it opened on.

Saving a new entry with nothing picked used to write an empty row: the
request succeeded, the sheet closed and the calendar had nothing to
draw, which read as a save that did nothing. Save now needs something to
store and says what, and an emptied existing entry still saves. The
sheet also stops writing until the opened date's own entry has loaded,
because until then it held the previous date's row id and a quick Save
patched the wrong day.

On the server, a start recorded a few days after an earlier one is the
same period entered in the other order: opening the earlier start now
folds the later one into it instead of leaving a one-day cycle in front
of it, and a bleed too far before the next start to be its first day and
too close to be a cycle of its own no longer infers a start. Opening or
removing a start re-attributes the day-logs whose cycle changed.
2026-09-24 08:02:00 +02:00
Marc-André Bombeck cc4934897c docs(api): describe the AI capability block and refusal meta
AccountPayload gains ai with the AiCapabilities, AiCapabilityState,
AiUnavailableReason and AiProviderState components. The feature-flags
route is marked deprecated, drops correlations and documents
documentAi. The error envelope's meta documents capability, reason and
module, lists the assistant.disabled switches, and the catalogue
carries the three new ai codes.
2026-09-24 07:51:16 +02:00
Marc-André Bombeck aa58495cbe Merge pull request #1007 from muhdusama/medfix/apple-health-scheduled-mirror
fix: allow scheduled Apple Health medication mirrors
2026-09-20 23:47:32 +02:00
muhdusama 24ff031f7a fix: preserve legacy compliance response contract 2026-09-21 07:17:13 +10:00
muhdusama 41d47c3184 fix: mark mirror adherence not applicable 2026-09-20 21:48:20 +10:00
Marc-André Bombeck 4f31b4b025 chore(release): v1.38.25 — readings that never arrived, and an import that said no 2026-09-20 10:53:24 +02:00
Marc-André Bombeck 6113855b19 chore(openapi): regenerate after the symptom severity change 2026-09-18 19:10:39 +02:00
Marc-André Bombeck 5188b28ea6 chore(release): v1.38.24 — Insights loads without the assistant, and the database stays current 2026-09-16 18:27:51 +02:00