diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9406d15a..e3d612e8a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -73,6 +73,12 @@ updates: # (runtime-package-pins.test.ts fails while the two differ). - dependency-name: "postgres" update-types: ["version-update:semver-major"] + # The Node major in the build and runner stages is the one `engines`, + # `.nvmrc` and `.node-version` name, and the native modules are built + # against it. Moving it is a release decision, so it arrives by hand. + # Digest and minor updates of the pinned major still come as PRs. + - dependency-name: "node" + update-types: ["version-update:semver-major"] # The `docker` ecosystem above reads the Dockerfile only. The bundled # database lives in `docker-compose.yml`, which GitHub treats as its own # ecosystem, so without this block a new PostgreSQL minor release never diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index dfdad5f61..a9169883b 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -142,6 +142,13 @@ jobs: output: trivy-published-image.sarif severity: "CRITICAL,HIGH,MEDIUM" limit-severities-for-sarif: true + # Only findings with a published fix reach code scanning. Debian + # leaves many base-package advisories unfixed in stable (no-dsa, + # postponed), and nothing in this repository can act on those: the + # build's `apt-get upgrade` takes the fix the day Debian ships one, + # and that is when the alert should appear. Listing them all made + # the page a hundred-odd rows deep without one to act on. + ignore-unfixed: true exit-code: "0" - name: Upload results @@ -162,6 +169,6 @@ jobs: format: table severity: "CRITICAL" exit-code: "1" - # Same rule as the blocking gate in security.yml: a finding with a - # published fix fails, one without is in the SARIF report above. + # Same rule as the blocking gate in security.yml and the SARIF + # report above: only a finding with a published fix counts. ignore-unfixed: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a4bc8c9..d5dc7c737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,169 @@ ## [Unreleased] +## [1.47.1] — 2026-11-07 + +### Added + +- `GET /api/integrations/healthkit` lists the HealthKit sample types the + server accepts as `acceptedTypes`, taken from the same mapping the batch + upload uses. The iOS app can then ask for and send only types the server + stores, instead of reading a type the server would skip. (#1340) +- `GET /api/sync/changes?start=now` answers an empty page with a cursor + that starts at the present. The iOS app can page forward from it to + write readings to Apple Health in the background, without first walking + years of history. (#1339) +- `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same + points and window the timeline draws for it, under the Labs module. The + iOS app can show a marker's chart with the timeline switched off. (#1342) +- Resting energy from Apple Health is stored as a metric of its own, + `BASAL_ENERGY_BURNED` in kcal, next to active energy and never added to + it. It used to be skipped. It is summed per day, charted, listed with your + measurements, included in backups and exports, and readable over MCP. It + feeds no score and no insight. (#1340) +- When the Coach looks for readings older than the lookback limit you set + for it, its live trail now says so on the wire: the `step` and `activity` + frames carry `reason: "outside_reach"` instead of looking like an empty + window. The native client can then tell "nothing in this window" from + "further back than the Coach may read". The answer still names the limit, + and the web trail keeps its wording. +- Turning on workout routes no longer helps only workouts recorded from + then on. When the app sends a route again for a workout that is already + stored without one, the server now keeps it, sealed like any other + track, and fills the workout's climb if it had none; the entry reports + `enriched`, as it does for a heart-rate series. A workout that already + has a route keeps it, and with routes switched off nothing is stored. +- **Lab markers grouped by category.** Under Settings, Arrangement, Labs, + the list can now group markers by their panel (Lipids, Liver, Thyroid + and so on), each group with a header that says whether its latest values + are all in range or how many are outside it. A catalog panel is + recognised in every language, a panel you typed yourself becomes its own + group, and a marker without a panel falls back to the catalog panel its + name belongs to, or to Other. You choose which groups open on load: all, + only those with a value outside range, or none. Grouping is off by + default. The server resolves the groups once: `GET /api/labs/overview` + carries `categories` and `grouped`, the layout preferences gain + `groupBy` and `groupsOpen`, `GET /api/biomarkers` lists the panels you + already use as `customPanels` for the panel field's suggestions, and a + merge preview names the panel the kept marker takes over as + `adoptedPanel`. No migration. (#1349) +- **Your cycle forecast as a calendar subscription.** Cycle, Settings, + Calendar subscription publishes the next period, and once three cycles + are logged up to six after it, as an ICS feed for Google, Apple or + Outlook calendar. Each event spans the window the period may start in, + wider the further ahead it is, and the feed stops where a date would be + more guess than estimate. Past cycles (up to twelve) and the fertile + window of the next cycle (only with the goal of trying to conceive) are + opt-in. Titles are a dot by default, and events never carry symptoms, + notes or other log details. The link is shown once, can be rotated or + turned off, and is turned off by a password change or a deletion + request. Only the record's owner, or a managed profile's guardian, can + turn it on, from a signed-in browser after confirming it is them. The + feed answers with an `ETag`, and every unknown or revoked link gets the + same plain 404. `GET /api/cycle/calendar` adds `projection` and `projectionState` + so the web and iOS calendars draw the same cycles ahead, and the Coach + and MCP cycle context list them as `upcoming`. Migration + `0494_cycle_calendar_feed`. Self-hosters: the feed link sits in the URL + path, so keep `/api/cycle/feed/` out of your proxy's access log; + `docs/self-hosting/reverse-proxy.md` shows how. (#1350) +- **Your own symptoms as lines on the timeline.** The timeline's values + menu has a Symptoms group with every symptom you have logged at least + once, hidden ones included. A line sits on a fixed 0 to 10 scale and + follows the zoom: each point is the mean of the bucket's days, where a + day counts with the highest intensity logged on it, and it says how many + days that was. A day without an entry is left out rather than counted as + zero. Symptoms follow the illness module and its sharing domain, so the + lines are only offered where the symptom log itself is. + `GET /api/timeline` takes `symptom:` in `values` (up to 24) and answers + with `symptomSeries` and `availableSymptoms`; with `zoom=all` the window + reaches back to the first entry of a chosen symptom. No migration. (#981) + +### Fixed + +- **The report offered for a visit is the report you get.** When the last + matching visit was more than two years ago, the next-visit card offered a + report from that day, but a report covers two years at most, and the + export quietly made a 90-day report instead. The window before a visit + now stops at two years and says "The last two years", and the visit list + marks it with `preparation.capped`. A report range longer than two years, + however it is asked for, now covers the last two years of it rather than + falling back to 90 days. (Two years across a clock change also no longer + count as one day too many.) +- **PDF previews show in Safari.** On macOS and iOS the preview of an + uploaded PDF stayed an empty box. The page's security policy told the + browser that nothing may frame it, and Safari applied that to the + preview the page itself opens. The policy now allows framing by the app + itself and still refuses every other site. (#1311) +- **Periods from Garmin and Oura open their cycles.** Period days imported + from a watch or a ring were stored as days only, so the cycle tracker saw + no cycle start, no lengths and no forecast. They now open and fill their + cycles like a period logged by hand, a finished Garmin period also marks + its last day, and periods imported earlier are repaired once on the next + start. A day you logged yourself is never changed. (#1333) +- **Garmin in the setup questions.** The onboarding question about where + your readings come from now offers Garmin, and can end on connecting it. + (#1333) +- **Fitbit in the setup questions leads to Google Health.** Fitbit shut its + classic connection down on 30 October, but answering "Fitbit" still ended + the setup on that card. Fitbit stays an answer, since the devices are still + in use; the last step now opens the Google Health card, reads that + connection, and says that Fitbit data comes through Google Health now. The + question shows the same note under the Fitbit choice. +- **Cycle days in the integration details.** The Garmin and Oura cards list + the newest imported cycle day and how many there are under "Data + received", next to the other data types. (#1333) +- **Typing into the smoking history.** "When you stopped" only took text + that was already a whole YYYY-MM month, so every keystroke was dropped + and only a paste got in. The start year clamped each keystroke into its + range, so the first digit turned into 1900. Both fields now keep what is + typed and commit it once it is a valid value, and leaving the month field + pads "2019-3" to "2019-03". (#1334) +- **Crisis lines for the country you are in.** A questionnaire answered + from a phone set to English in Belgium (`en-BE`), French in Switzerland, + or English in Austria got the international crisis set, because the + country was only read for some languages. The region in the language tag + now picks the country's lines whatever the language; a bare language + code keeps the combined sets as before. (#1338) +- **SSO sign-in in the app names the deletion date.** Signing in with SSO + on the phone to an account pending deletion now carries the date the + account is erased, as the web sign-in already did, so the app can say + until when an admin can still undo it. (#1341) +- **A marker started from a text result keeps its range.** When a report + printed a text result such as "SEE NOTE" with a numeric range beside it, + the new marker was saved without that range, so its first number later + had nothing to be judged against. The range now goes onto the new marker, + from a document, a lab scan, a CSV import or a typed reading, and the + scan review shows it for such a row. A range that is text itself, like + "NEGATIVE", is not turned into bounds, and a marker that already exists + keeps its own. (#1313) +- **Several files dropped at once all arrive.** The server takes two + uploads per person at a time and asks the next one to come back in a + second, but the documents page sent three at once and showed that + answer as an error telling you to wait a few minutes. The page now sends + two at a time, and a file the server turns away for a moment, say while + another tab is still uploading, waits and goes again on its own for up + to half a minute before it reports anything. Attaching a new file in the + Coach does the same. Reaching the hourly upload limit now says that + instead of "a few minutes". +- **Counts in Russian, French and Portuguese.** Russian uses its singular + form for 21, 31 and so on, and French and Brazilian Portuguese use it for + zero, but those strings had the digit 1 written in, so "21 days" read as + "1 day". They now carry the actual count, and a guard keeps new ones from + writing the digit. + +### Changed + +- Dependabot no longer proposes a new Node major for the image. The major + is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand. + +### Security + +- `@hono/node-server` moves to the 2.x line, which fixes a second decode of + the request path in `serveStatic`. It arrives under the MCP SDK; the app + uses the SDK's web-standard transport and never loads it, but the copy + was in the tree. `systeminformation`, under the shadcn CLI at dev time, + moves past a Windows command injection. + ## [1.47.0] — 2026-11-04 ### Added diff --git a/Dockerfile b/Dockerfile index e5adabd18..90654a917 100644 --- a/Dockerfile +++ b/Dockerfile @@ -133,7 +133,7 @@ RUN mkdir -p /opt/prisma-cli && \ npm init -y && \ npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ npm pkg set 'overrides.mysql2=^3.22.0' && \ - npm pkg set 'overrides.@hono/node-server=^1.19.15' && \ + npm pkg set 'overrides.@hono/node-server=^2.1.3' && \ npm pkg set 'overrides.valibot=^1.4.2' && \ npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \ npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit diff --git a/docs/README.md b/docs/README.md index b500f6784..58ccae26c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ notes, and machine-readable specs that ship alongside the source: code. [`using/search.md`](./using/search.md) covers the command palette's record search: opening it, what it finds, how matching works, and its limits on very large and shared records. + [`using/cycle-calendar-feed.md`](./using/cycle-calendar-feed.md) covers + subscribing to the cycle forecast from Google, Apple or Outlook. - [`migration/`](./migration/) — migration notes. Currently only v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and . diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b9712d115..45964de7d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.47.0 + version: 1.47.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -399,7 +399,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a16 + "429": &a12 description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated @@ -770,7 +770,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a18 + "400": &a11 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -782,6 +782,202 @@ paths: "403": *a9 "422": *a4 "429": *a5 + /api/cycle/feed/{token}: + get: + tags: + - Cycle + summary: "The subscribable cycle forecast as iCalendar (public, #1350)" + description: "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by + `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every + gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's + external-access switch is on, cycle tracking is available, and a feed minted by a managed profile's guardian + still sits on a managed profile the guardian holds a live MANAGE grant over. Every miss (malformed, unknown, + revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, + discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, + for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers + 304." + security: [] + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hlf_` feed token, optionally with `.ics`. + required: true + description: Raw `hlf_` feed token, optionally with `.ics`. + responses: + "200": + description: "The calendar. `Cache-Control: private, no-cache, max-age=0`, `ETag`." + content: + text/calendar: + schema: + type: string + "304": + description: The body is unchanged since the `If-None-Match` ETag. + "404": + description: Flat 404 for every miss class (errorCode `cycle.feed.notFound`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: The source-address or per-token bucket is exhausted. + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/cycle/feed-subscription: + get: + tags: + - Cycle + summary: Calendar subscription status (#1350) + description: The record's live calendar subscription without its secret (prefix, options, fetch statistics) and what the + feed serves right now. Never the hash, never the URL. + responses: + "200": + description: The status; `feed` is null while the subscription is off. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedStatus" + "401": *a1 + "403": &a10 + description: >- + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Calendar subscriptions are + managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched + off for the record answers `cycle.disabled`; the operator's external-access switch answers + `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner on their own + record and by the guardian of a managed profile; a delegate on an adult's record is refused at every grant + level, MANAGE included. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Cycle + summary: Turn the calendar subscription on (#1350) + description: "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live + subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles + ahead, no history, no fertile window, discreet titles. Rate-limited per acting account + (`cycle-feed:mint:`, 10/h, shared with rotate)." + responses: + "201": &a13 + description: The new link, returned exactly once. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedMinted" + "401": &a14 + description: "Not signed in, or the session has no fresh proof: turning the subscription on or rotating it needs a + sign-in or `POST /api/auth/reproof` within the last five minutes, answered otherwise with 401 + `auth.reproof.required` and `meta.methods`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": *a10 + "409": + description: A subscription is already on (`cycle.feed.exists`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + patch: + tags: + - Cycle + summary: Change the calendar subscription's options (#1350) + description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedOptionsPatch" + responses: + "200": + description: The status after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedStatusAfterPatch" + "400": *a11 + "401": *a1 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a12 + delete: + tags: + - Cycle + summary: Turn the calendar subscription off (#1350) + description: Revokes the link; calendars holding it stop updating on their next poll. Copies a calendar provider already + holds are beyond the app's reach. + responses: + "200": + description: Revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedRevoked" + "401": *a1 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a12 + /api/cycle/feed-subscription/rotate: + post: + tags: + - Cycle + summary: Replace the calendar subscription's link (#1350) + description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL + EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers + 409 `cycle.feed.exists` and the winner's link stands." + responses: + "201": *a13 + "401": *a14 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Another rotation replaced the link at the same moment (`cycle.feed.exists`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/measurement-categories: get: tags: @@ -983,7 +1179,7 @@ paths: type: string format: binary "401": *a1 - "403": &a15 + "403": &a20 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account @@ -1309,7 +1505,7 @@ paths: schema: type: string format: binary - "400": &a10 + "400": &a15 description: A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`. @@ -1319,7 +1515,7 @@ paths: type: string format: binary "401": *a1 - "403": &a11 + "403": &a16 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` @@ -1330,7 +1526,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": &a12 + "429": &a17 description: "Rate limit exceeded: the per-account `fhir:` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, @@ -1384,11 +1580,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationStatement: get: tags: @@ -1415,11 +1611,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationAdministration: get: tags: @@ -1446,11 +1642,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Coverage: get: tags: @@ -1478,11 +1674,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Patient/{id}: get: tags: @@ -1506,7 +1702,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1515,7 +1711,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Coverage/{id}: get: tags: @@ -1539,7 +1735,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1548,7 +1744,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Observation/{id}: get: tags: @@ -1573,7 +1769,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1582,7 +1778,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationStatement/{id}: get: tags: @@ -1606,7 +1802,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1615,7 +1811,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationAdministration/{id}: get: tags: @@ -1639,7 +1835,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1648,7 +1844,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Patient/$everything: get: tags: @@ -1682,11 +1878,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/export: get: tags: @@ -2219,7 +2415,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a13 + "409": &a18 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2255,7 +2451,7 @@ paths: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 "403": *a9 - "409": *a13 + "409": *a18 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2565,7 +2761,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a14 + "401": &a19 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: @@ -2718,7 +2914,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a14 + "401": *a19 "403": *a9 "422": *a4 "429": *a5 @@ -2872,7 +3068,7 @@ paths: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 "403": *a9 - "409": *a13 + "409": *a18 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -3238,7 +3434,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a13 + "409": *a18 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3926,7 +4122,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a20 + "429": &a23 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -4058,11 +4254,13 @@ paths: drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, - `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. - `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; - the owner links it by signing in once with that account's own password or passkey in a browser. The reasons - are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic - sign-in failure. + `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `account_deletion_pending`, + `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is + not linked yet; the owner links it by signing in once with that account's own password or passkey in a + browser. `account_deletion_pending` means the account is locked pending deletion; when the purge date is + known it rides beside the reason as `purgeAt=` on both legs, and nothing else about the + account does. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does + not know to a generic sign-in failure. /api/auth/passkey/register-options: post: tags: @@ -4321,6 +4519,23 @@ paths: maxLength: 2048 description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync. + - in: query + name: start + schema: + description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before + `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields + what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` + can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), + including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). + A server without it ignores the parameter and starts from the oldest row." + type: string + const: now + description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before + `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what + changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can + commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including + `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server + without it ignores the parameter and starts from the oldest row." - in: query name: limit schema: @@ -4372,7 +4587,7 @@ paths: schema: $ref: "#/components/schemas/GetBloodPressureTimeOfDayResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/series-batch: @@ -4422,7 +4637,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/series: @@ -4477,7 +4692,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements: @@ -4595,6 +4810,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - in: query name: from schema: @@ -4725,7 +4941,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -4781,7 +4997,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4797,7 +5013,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/measurements/{id}: get: tags: @@ -4818,7 +5034,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -4866,7 +5082,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -4905,7 +5121,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -5019,7 +5235,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -5051,7 +5267,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -5084,7 +5300,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a17 + "403": &a21 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: @@ -5127,7 +5343,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/workouts: @@ -5178,7 +5394,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -5214,7 +5430,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": *a11 + "403": *a16 "404": description: Workout not found (or owned by another user). content: @@ -5273,10 +5489,13 @@ paths: accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is - attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry - whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the - batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling - receive a 413 with `workout.batch.payload_too_large`. + attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). The same + holds for a `route` while the account keeps tracks (`storeWorkoutRoutes`): a stored workout without a track gets + the one the entry carries, sealed like any other, and the entry reports `enriched`; a workout that already has a + track keeps it. An entry that attaches both reports one `enriched`. An entry whose `samples` array fails + validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The + request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with + `workout.batch.payload_too_large`. **Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the @@ -5366,7 +5585,7 @@ paths: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/activity/goals: @@ -5409,7 +5628,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a16 + "429": *a12 /api/measurements/provenance: get: tags: @@ -5516,6 +5735,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: The metric. required: true description: The metric. @@ -5538,7 +5758,7 @@ paths: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/provenance/compare: @@ -5645,6 +5865,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: The metric. required: true description: The metric. @@ -5722,7 +5943,7 @@ paths: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/devices: @@ -5834,7 +6055,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -5964,7 +6185,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/medications/{id}/glp1: get: tags: @@ -5989,7 +6210,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6073,7 +6294,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6212,7 +6433,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6330,7 +6551,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6369,7 +6590,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6414,7 +6635,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -6462,7 +6683,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -6510,7 +6731,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No event with that id under this medication and record. content: @@ -6553,7 +6774,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6595,7 +6816,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6640,7 +6861,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6721,7 +6942,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -6760,7 +6981,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -6780,7 +7001,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -6807,7 +7028,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -6978,7 +7199,7 @@ paths: schema: $ref: "#/components/schemas/EndMedicationCourseResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (`medications.course.notFound`), or another user's. content: @@ -7084,7 +7305,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -7119,7 +7340,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a19 + "422": &a22 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -7165,7 +7386,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7206,7 +7427,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7280,7 +7501,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7298,7 +7519,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 get: tags: - Medications @@ -7363,7 +7584,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7396,7 +7617,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7432,7 +7653,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7476,7 +7697,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7511,7 +7732,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7609,7 +7830,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7658,7 +7879,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -7843,7 +8064,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7881,7 +8102,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7925,7 +8146,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication or revision not found (or owned by another user). content: @@ -7966,7 +8187,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication or revision not found (or owned by another user). content: @@ -8029,7 +8250,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A dose-history import is already in progress. content: @@ -8091,7 +8312,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such job for this medication (or owned by another user). content: @@ -8125,7 +8346,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such job for this account (or owned by another user). content: @@ -8155,7 +8376,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/ai/test: @@ -8365,7 +8586,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -8376,7 +8597,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/disable-coach: get: @@ -8415,7 +8636,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -8576,7 +8797,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -8587,7 +8808,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/source-priority: get: @@ -9763,7 +9984,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -9923,7 +10144,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a20 + "429": *a23 /api/auth/me/notification-prefs: get: tags: @@ -9966,7 +10187,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -9977,7 +10198,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/report-selection: get: @@ -10020,7 +10241,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -10069,7 +10290,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" - "400": &a21 + "400": &a24 description: The request body is not parseable JSON. Nothing was written. content: application/json: @@ -10077,13 +10298,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "413": &a22 + "413": &a25 description: Request body exceeds 1024 bytes. Nothing was written. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a23 + "415": &a26 description: "The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written." content: application/json: @@ -10139,11 +10360,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/documents-auto-ai-read: @@ -10199,11 +10420,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/injection-site-prefs: @@ -10505,11 +10726,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/workout-routes: @@ -10550,11 +10771,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/use-central-codex: @@ -10600,11 +10821,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/locale: @@ -10811,7 +11032,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a26 + "503": &a29 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -11230,7 +11451,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 delete: tags: @@ -11412,7 +11633,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -11533,7 +11754,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/anamnesis/facts: get: @@ -11552,7 +11773,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -12027,7 +12248,7 @@ paths: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 "403": *a9 - "404": &a24 + "404": &a27 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -12064,7 +12285,7 @@ paths: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a27 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -12435,7 +12656,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -12603,7 +12824,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 - "403": &a25 + "403": &a28 description: The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`. content: application/json: @@ -12644,7 +12865,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 /api/admin/invites/{id}: @@ -12669,7 +12890,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: @@ -12725,7 +12946,7 @@ paths: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 post: @@ -12855,7 +13076,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -12877,7 +13098,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/cards: @@ -12898,7 +13119,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -13036,7 +13257,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a26 + "503": *a29 post: tags: - Insights @@ -13255,7 +13476,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/narrative: @@ -13304,7 +13525,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -13341,7 +13562,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -13377,7 +13598,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/settings: @@ -13453,7 +13674,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/analytics/range: @@ -13535,7 +13756,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -13575,7 +13796,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -13615,7 +13836,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -13655,7 +13876,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -13695,7 +13916,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -13736,7 +13957,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -13843,7 +14064,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -13896,7 +14117,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/derived: @@ -14008,7 +14229,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -14048,7 +14269,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/score-history: @@ -14098,7 +14319,7 @@ paths: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -14118,7 +14339,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/ecg: @@ -14140,7 +14361,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -14218,7 +14439,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -14250,7 +14471,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/correlations: @@ -14272,7 +14493,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/patterns: @@ -14290,7 +14511,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -14320,7 +14541,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No current pattern with that id for this account. content: @@ -14345,7 +14566,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -14364,7 +14585,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -14383,7 +14604,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/mood/analytics: @@ -14406,7 +14627,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/mood/insights: @@ -14441,7 +14662,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -14464,7 +14685,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -14578,7 +14799,7 @@ paths: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 "403": *a9 - "404": &a27 + "404": &a30 description: Not found / not owned by the caller. content: application/json: @@ -14614,7 +14835,7 @@ paths: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -14651,7 +14872,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -14706,7 +14927,7 @@ paths: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14738,7 +14959,7 @@ paths: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -14788,7 +15009,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/mood-entries: get: @@ -14872,7 +15093,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -14905,8 +15126,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "409": &a28 + "403": *a20 + "409": &a31 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -14915,7 +15136,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/mood-entries/{id}: get: tags: @@ -14937,8 +15158,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "404": *a27 + "403": *a20 + "404": *a30 "422": *a4 "429": *a5 put: @@ -14972,9 +15193,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "404": *a27 - "409": *a28 + "403": *a20 + "404": *a30 + "409": *a31 "422": *a4 "429": *a5 delete: @@ -14999,8 +15220,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a15 - "404": *a27 + "403": *a20 + "404": *a30 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -15029,7 +15250,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -15058,7 +15279,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -15102,7 +15323,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -15151,7 +15372,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a29 + "400": &a32 description: Body is not parseable JSON. content: application/json: @@ -15166,13 +15387,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a30 + "413": &a33 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a31 + "415": &a34 description: Content-Type is not `application/json`. content: application/json: @@ -15233,7 +15454,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a29 + "400": *a32 "401": *a1 "403": description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the @@ -15249,8 +15470,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a30 - "415": *a31 + "413": *a33 + "415": *a34 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -15305,11 +15526,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a29 + "400": *a32 "401": *a1 "403": *a9 - "413": *a30 - "415": *a31 + "413": *a33 + "415": *a34 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -15336,7 +15557,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/settings/global-services: @@ -15957,7 +16178,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a32 + "400": &a35 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -16018,7 +16239,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a32 + "400": *a35 "401": *a1 "403": *a9 "422": *a4 @@ -16765,7 +16986,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -16790,7 +17011,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -16814,8 +17035,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a15 - "404": &a33 + "403": *a20 + "404": &a36 description: Measurement reminder not found / not owned. content: application/json: @@ -16852,8 +17073,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16875,8 +17096,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -16900,8 +17121,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -16928,8 +17149,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -16958,8 +17179,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -16992,8 +17213,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -17033,8 +17254,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/labs: @@ -17112,7 +17333,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17139,7 +17360,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -17154,7 +17375,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/labs/overview: get: tags: @@ -17162,7 +17383,8 @@ paths: summary: Per-marker overview of the caller's lab history description: "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut - off by a page size." + off by a page size. `categories` and `grouped` carry the list's categories, resolved server-side; their labels + follow the caller's locale (the language cookie, else the caller's stored language, else `Accept-Language`)." parameters: [] responses: "200": @@ -17172,7 +17394,7 @@ paths: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/labs/{id}: @@ -17195,8 +17417,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a15 - "404": &a34 + "403": *a20 + "404": &a37 description: Lab result not found (or owned by another user). content: application/json: @@ -17230,8 +17452,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a15 - "404": *a34 + "403": *a20 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -17253,8 +17475,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a15 - "404": *a34 + "403": *a20 + "404": *a37 "422": *a4 "429": *a5 /api/labs/restore: @@ -17280,7 +17502,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -17342,7 +17564,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a44 + "202": &a47 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -17466,7 +17688,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17491,7 +17713,7 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A biomarker with this name already exists for the caller. content: @@ -17499,7 +17721,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/biomarkers/{id}: get: tags: @@ -17522,8 +17744,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a15 - "404": &a35 + "403": *a20 + "404": &a38 description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one." @@ -17564,8 +17786,8 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "409": description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; @@ -17598,10 +17820,91 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "422": *a4 "429": *a5 + /api/biomarkers/{id}/series: + get: + tags: + - Labs + summary: One marker's lab line + description: "The line `labSeries` on `GET /api/timeline` draws for this marker, cut by the same `zoom`, `from` and `to` + rules: every numeric reading in the window, oldest first, one point each (never averaged), readings in a unit + that does not convert counted in `excluded`, and the band to draw behind them. `all` starts at the marker's + first reading. Gated on the `labs` module, not on `timeline`. A point names the report it was read from only + when the caller may see documents. Cross-user ids answer 404; a merged marker answers 404 with + `meta.mergedInto`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: zoom + schema: + default: all + description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` + (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points + are never averaged, whatever the zoom." + type: string + enum: + - all + - year + - quarter + - range + description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` + (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points + are never averaged, whatever the zoom." + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The marker's line over the window. + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerSeriesEnvelope" + "401": *a1 + "403": + description: >- + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with + `meta.module = labs`: the record has the labs module switched off, or the operator turned it off + server-wide. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": *a38 + "422": + description: "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15 + years, or `from` after `to`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/biomarkers/{id}/merge: post: tags: @@ -17636,8 +17939,8 @@ paths: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in @@ -17648,7 +17951,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/encounters: get: tags: @@ -17711,7 +18014,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17737,10 +18040,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/encounters/procedures: get: tags: @@ -17771,7 +18074,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/body-sites: @@ -17808,7 +18111,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -17838,7 +18141,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -17861,8 +18164,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a15 - "404": &a36 + "403": *a20 + "404": &a39 description: Visit not found (or owned by another user). content: application/json: @@ -17895,8 +18198,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 delete: @@ -17920,8 +18223,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -17946,8 +18249,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -17979,7 +18282,7 @@ paths: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a36 + "404": *a39 "422": *a4 "429": *a5 delete: @@ -18009,7 +18312,7 @@ paths: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a36 + "404": *a39 "422": *a4 "429": *a5 /api/practitioners: @@ -18049,7 +18352,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18073,7 +18376,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -18097,8 +18400,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a15 - "404": &a37 + "403": *a20 + "404": &a40 description: Practitioner not found (or owned by another user). content: application/json: @@ -18131,8 +18434,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 delete: @@ -18156,8 +18459,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -18182,8 +18485,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -18201,7 +18504,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18226,8 +18529,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "409": &a38 + "403": *a20 + "409": &a41 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -18235,7 +18538,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/vaccinations/custom/{id}: patch: tags: @@ -18263,14 +18566,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "404": &a39 + "403": *a20 + "404": &a42 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a38 + "409": *a41 "422": *a4 "429": *a5 delete: @@ -18294,8 +18597,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "404": *a39 + "403": *a20 + "404": *a42 "422": *a4 "429": *a5 /api/vaccinations: @@ -18348,7 +18651,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18375,7 +18678,7 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). @@ -18385,7 +18688,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/vaccinations/{id}: get: tags: @@ -18406,8 +18709,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": &a40 + "403": *a20 + "404": &a43 description: Vaccination not found (or owned by another user). content: application/json: @@ -18441,8 +18744,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 delete: @@ -18466,8 +18769,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -18491,8 +18794,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -18560,7 +18863,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -18588,7 +18891,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -18621,7 +18924,7 @@ paths: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 delete: @@ -18651,7 +18954,7 @@ paths: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 /api/illness/episodes: @@ -18715,7 +19018,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18741,7 +19044,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -18765,8 +19068,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": &a41 + "403": *a20 + "404": &a44 description: Illness episode not found (or owned by another user). content: application/json: @@ -18800,8 +19103,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 delete: @@ -18824,8 +19127,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -18852,8 +19155,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "409": *a7 "422": *a4 "429": *a5 @@ -18885,8 +19188,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -18936,8 +19239,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 post: @@ -18973,8 +19276,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -19002,7 +19305,7 @@ paths: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 "403": *a9 - "404": *a41 + "404": *a44 "422": *a4 "429": *a5 /api/illness/insights: @@ -19076,7 +19379,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19109,7 +19412,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -19136,7 +19439,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/allergies: @@ -19169,7 +19472,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19194,10 +19497,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/allergies/{id}: get: tags: @@ -19218,8 +19521,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a15 - "404": &a42 + "403": *a20 + "404": &a45 description: Allergy not found (or owned by another user). content: application/json: @@ -19252,8 +19555,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a15 - "404": *a42 + "403": *a20 + "404": *a45 "422": *a4 "429": *a5 delete: @@ -19276,8 +19579,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a15 - "404": *a42 + "403": *a20 + "404": *a45 "422": *a4 "429": *a5 /api/family-history: @@ -19301,7 +19604,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19326,7 +19629,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -19350,8 +19653,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": &a43 + "403": *a20 + "404": &a46 description: Family history entry not found (or owned by another user). content: application/json: @@ -19384,8 +19687,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a43 + "403": *a20 + "404": *a46 "422": *a4 "429": *a5 delete: @@ -19408,8 +19711,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a43 + "403": *a20 + "404": *a46 "422": *a4 "429": *a5 /api/documents/inbound: @@ -19524,7 +19827,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19817,7 +20120,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/layout: @@ -19835,7 +20138,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -19906,7 +20209,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 patch: @@ -20083,7 +20386,7 @@ paths: type: string format: binary "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -20112,7 +20415,7 @@ paths: type: string format: binary "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -20156,7 +20459,7 @@ paths: required: true schema: type: string - - &a45 + - &a48 name: Prefer in: header required: false @@ -20181,9 +20484,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": &a46 + "403": &a49 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and @@ -20209,7 +20512,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a47 + "503": &a50 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -20239,7 +20542,7 @@ paths: required: true schema: type: string - - *a45 + - *a48 requestBody: required: false content: @@ -20275,12 +20578,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/summary: post: tags: @@ -20330,7 +20633,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a45 + - *a48 requestBody: required: false content: @@ -20366,12 +20669,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/index: post: tags: @@ -20399,7 +20702,7 @@ paths: required: true schema: type: string - - *a45 + - *a48 requestBody: required: false content: @@ -20435,12 +20738,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/chat: post: tags: @@ -20480,10 +20783,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 get: tags: - Documents @@ -20547,10 +20850,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -20634,7 +20937,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -20660,7 +20963,7 @@ paths: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 "403": *a9 - "409": &a49 + "409": &a52 description: A custom metric with this name already exists for the caller. content: application/json: @@ -20688,8 +20991,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a15 - "404": &a48 + "403": *a20 + "404": &a51 description: Custom metric not found (or owned by another user). content: application/json: @@ -20724,8 +21027,8 @@ paths: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 "403": *a9 - "404": *a48 - "409": *a49 + "404": *a51 + "409": *a52 "422": *a4 "429": *a5 delete: @@ -20749,7 +21052,7 @@ paths: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 "403": *a9 - "404": *a48 + "404": *a51 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -20792,8 +21095,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a15 - "404": *a48 + "403": *a20 + "404": *a51 "422": *a4 "429": *a5 post: @@ -20823,10 +21126,10 @@ paths: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": *a48 + "404": *a51 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -20860,7 +21163,7 @@ paths: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": &a50 + "404": &a53 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -20894,7 +21197,7 @@ paths: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": *a50 + "404": *a53 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -21202,7 +21505,7 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a51 + "403": &a54 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is @@ -21351,7 +21654,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -21388,7 +21691,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "422": *a4 "429": *a5 /api/nutrients/water: @@ -21417,7 +21720,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "409": *a7 "422": *a4 "429": *a5 @@ -22129,7 +22432,7 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a52 + "400": &a55 description: Body is not parseable JSON. content: application/json: @@ -22137,19 +22440,19 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "413": &a53 + "413": &a56 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a54 + "415": &a57 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a55 + "422": &a58 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -22340,12 +22643,12 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22500,12 +22803,12 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22671,12 +22974,12 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22756,21 +23059,21 @@ paths: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 "403": *a9 - "422": &a56 + "422": &a59 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a57 + "429": &a60 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a58 + "502": &a61 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -22816,12 +23119,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22901,9 +23204,9 @@ paths: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/strava/credentials: get: tags: @@ -22940,12 +23243,12 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -23027,9 +23330,9 @@ paths: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/status: get: tags: @@ -23067,7 +23370,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/fitbit/test: post: tags: @@ -23093,9 +23396,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/google-health/resume: post: tags: @@ -23113,7 +23416,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/google-health/test: post: tags: @@ -23147,9 +23450,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/whoop/resume: post: tags: @@ -23167,7 +23470,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/whoop/test: post: tags: @@ -23185,9 +23488,9 @@ paths: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/withings/resume: post: tags: @@ -23206,7 +23509,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/withings/test: post: tags: @@ -23225,8 +23528,8 @@ paths: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 + "422": *a59 + "429": *a60 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -23259,7 +23562,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -23352,7 +23655,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -23371,7 +23674,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -23406,7 +23709,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 delete: tags: @@ -23644,7 +23947,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a59 + "403": &a62 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never @@ -23671,7 +23974,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "422": *a4 "429": *a5 post: @@ -23775,7 +24078,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such invitation for this account. content: @@ -23819,7 +24122,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such grant on this account's record. content: @@ -23860,7 +24163,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such grant held by this account. content: @@ -24259,7 +24562,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -24648,7 +24951,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a60 + "403": &a63 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as @@ -24682,7 +24985,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -24709,7 +25012,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "422": *a4 "429": *a5 post: @@ -24742,7 +25045,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "413": description: Body exceeds 16 KiB. content: @@ -24785,7 +25088,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -24812,7 +25115,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -24831,7 +25134,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: @@ -24847,7 +25150,7 @@ paths: record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - - &a61 + - &a64 name: provider in: path required: true @@ -24869,7 +25172,7 @@ paths: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: @@ -24894,7 +25197,7 @@ paths: Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - - *a61 + - *a64 responses: "200": description: The provider is disconnected. @@ -24903,7 +25206,7 @@ paths: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: @@ -24920,7 +25223,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a62 + - &a65 name: family in: path required: true @@ -24944,7 +25247,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -24964,7 +25267,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a62 + - *a65 requestBody: required: true content: @@ -24985,7 +25288,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -25099,7 +25402,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -25152,7 +25455,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/environment/preferences: @@ -25178,7 +25481,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment: @@ -25196,7 +25499,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/home: @@ -25233,7 +25536,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -25293,7 +25596,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot @@ -25353,7 +25656,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -25392,7 +25695,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -25445,7 +25748,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -25677,7 +25980,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a25 + "403": *a28 "404": description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored. content: @@ -25746,13 +26049,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a63 + "403": &a66 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a64 + "404": &a67 description: No stored copy with this id. content: application/json: @@ -25826,8 +26129,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a63 - "404": *a64 + "403": *a66 + "404": *a67 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -25871,7 +26174,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a63 + "403": *a66 /api/admin/backups/restores/{jobId}: get: tags: @@ -25894,7 +26197,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a63 + "403": *a66 "404": description: No restore job with this id. content: @@ -25913,7 +26216,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a65 + security: &a68 - cookieAuth: [] responses: "200": @@ -25923,7 +26226,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a66 + "403": &a69 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another @@ -25948,9 +26251,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - &a67 + - &a70 name: system in: path required: true @@ -25989,8 +26292,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a66 - "404": &a68 + "403": *a69 + "404": &a71 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -26009,7 +26312,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a69 + "502": &a72 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -26028,9 +26331,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: Removed, or there was nothing to remove. @@ -26039,7 +26342,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a66 + "403": *a69 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -26050,9 +26353,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: The source answered. @@ -26061,9 +26364,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": &a70 + "403": *a69 + "404": *a71 + "422": &a73 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -26072,7 +26375,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/tags: get: tags: @@ -26080,9 +26383,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: The tags. @@ -26091,11 +26394,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": *a70 + "403": *a69 + "404": *a71 + "422": *a73 "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/search: get: tags: @@ -26109,9 +26412,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 - name: q in: query required: false @@ -26152,11 +26455,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": *a70 + "403": *a69 + "404": *a71 + "422": *a73 "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/import: post: tags: @@ -26178,9 +26481,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 requestBody: required: true content: @@ -26241,7 +26544,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a66 + "403": *a69 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -26272,7 +26575,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a70 + "422": *a73 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -26280,7 +26583,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a69 + "502": *a72 /api/symptoms/definitions: get: tags: @@ -26304,7 +26607,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a71 + "403": &a74 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: `illness.disabled`: the record @@ -26348,7 +26651,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 + "403": *a74 "409": *a7 "422": *a4 "429": *a5 @@ -26378,8 +26681,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 - "404": &a72 + "403": *a74 + "404": &a75 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -26413,8 +26716,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 - "404": *a72 + "403": *a74 + "404": *a75 "422": *a4 "429": *a5 /api/symptoms/events: @@ -26463,7 +26766,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a71 + "403": *a74 "422": *a4 "429": *a5 post: @@ -26489,8 +26792,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": *a72 + "403": *a74 + "404": *a75 "409": *a7 "422": *a4 "429": *a5 @@ -26520,8 +26823,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": &a73 + "403": *a74 + "404": &a76 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -26548,8 +26851,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": *a73 + "403": *a74 + "404": *a76 "422": *a4 "429": *a5 /api/day/index: @@ -26582,7 +26885,7 @@ paths: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/day/notable: @@ -26617,7 +26920,7 @@ paths: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/day/{date}: @@ -26647,7 +26950,7 @@ paths: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/timeline: @@ -26656,9 +26959,12 @@ paths: - Records summary: The record over the years description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the - standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of - a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is - never sent to a delegate. Requires the opt-in `timeline` module. + standing items without a start, and monthly (or finer, by zoom) value series, among them the lab markers + (`labSeries`, single readings) and the person-defined symptoms (`symptomSeries`, the day's highest intensity + averaged over logged days, on a fixed 0 to 10 scale; read only with the illness module on and the illness + section visible) named in `values`. Empty lanes are not sent; lanes of a switched-off module never are. The + `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the + opt-in `timeline` module. parameters: - in: query name: zoom @@ -26687,19 +26993,22 @@ paths: - in: query name: values schema: - description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as - `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab - markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones - left out without an error. A lab marker that is not in the record (deleted, or another record's) is - likewise left out of `labSeries`, so a stored choice never breaks the page." + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as + `lab:` (see `availableLabs`), or a person-defined symptom as `symptom:` (see + `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an + unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 + symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any + further ones left out without an error. A lab marker or symptom that is not in the record (deleted, or + another record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks + the page." type: string - description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as - `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers - are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out - without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out - of `labSeries`, so a stored choice never breaks the page." + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as + `lab:` (see `availableLabs`), or a person-defined symptom as `symptom:` (see + `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an + unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 + symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any further + ones left out without an error. A lab marker or symptom that is not in the record (deleted, or another + record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks the page." responses: "200": description: The timeline for the requested zoom and window. @@ -26708,7 +27017,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a74 + "403": &a77 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with @@ -26747,7 +27056,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a74 + "403": *a77 "422": *a4 "429": *a5 /api/life-events: @@ -26765,7 +27074,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a75 + "403": &a78 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Owner-only in v1.42: no share @@ -26807,10 +27116,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a75 + "403": *a78 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/life-events/{id}: patch: tags: @@ -26838,8 +27147,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a75 - "404": &a76 + "403": *a78 + "404": &a79 description: No such life event on this account. content: application/json: @@ -26866,8 +27175,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a75 - "404": *a76 + "403": *a78 + "404": *a79 "422": *a4 "429": *a5 /api/search: @@ -26913,7 +27222,7 @@ paths: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/auth/me/ai-usage: @@ -27042,7 +27351,7 @@ paths: required: - file responses: - "202": *a44 + "202": *a47 "401": *a1 "403": description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the @@ -27078,7 +27387,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a26 + "503": *a29 /api/anamnesis/life-circumstances: get: tags: @@ -27093,7 +27402,7 @@ paths: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: @@ -27182,7 +27491,7 @@ paths: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -27206,7 +27515,7 @@ paths: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: "`anamnesis.auditC.notFound`." content: @@ -27237,7 +27546,7 @@ paths: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/dawarich/status: @@ -27253,7 +27562,7 @@ paths: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/dawarich/places: @@ -27277,7 +27586,7 @@ paths: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27315,7 +27624,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27347,7 +27656,7 @@ paths: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 "502": @@ -27383,7 +27692,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27418,7 +27727,7 @@ paths: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/travel/suggestions: @@ -27436,7 +27745,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: @@ -27459,7 +27768,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "404": description: "`environment.travel_suggestion.not_found`." content: @@ -28405,6 +28714,27 @@ components: - type: "null" description: Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched. + CycleFeedOptionsPatch: + type: object + properties: + horizonCycles: + type: integer + minimum: 1 + maximum: 6 + historyCycles: + type: integer + minimum: 0 + maximum: 12 + includeFertile: + type: boolean + titleMode: + type: string + enum: + - DISCREET + - DESCRIPTIVE + additionalProperties: false + description: "The writable options. At least one field. `includeFertile: true` is refused (422 + `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE." HealthRecordExportRequest: type: object properties: @@ -28437,7 +28767,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -28499,7 +28829,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -29378,6 +29708,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED value: type: number measuredAt: @@ -31111,6 +31442,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED biomarkerId: type: string minLength: 1 @@ -31610,7 +31942,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -32812,6 +33144,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -34012,6 +34345,17 @@ components: - alphaAsc - alphaDesc - manual + groupBy: + type: string + enum: + - none + - category + groupsOpen: + type: string + enum: + - expanded + - flagged + - collapsed required: - version - list @@ -36598,7 +36942,7 @@ components: type: string const: done sources: - maxItems: 10 + maxItems: 11 type: array items: type: string @@ -36609,6 +36953,7 @@ components: - oura - whoop - polar + - garmin - fitbit - strava - nightscout @@ -39336,26 +39681,27 @@ components: coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; - dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, - dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — - demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, - documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, - documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.feed.api_disabled, + cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, cycle.feed.none, + cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, cycle.symptom.custom.invalid, + cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; + disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — + dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, + dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -39378,74 +39724,75 @@ components: insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, - labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, - labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge, - labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid, - labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, - labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — - life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, - managed_profile.guardian.managed_invitee, managed_profile.guardian.required, - managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian, - managed_profile.not_found; maintenance — maintenance.pulseDayMean.busy, - maintenance.pulseDayMean.dryRunRequired; measurement — measurement.batch.invalid, - measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, - measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, - measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; - medication — medication.intake.import.invalid_format, medication.intake.notTracked, - medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached, - medications.category.unknown, medications.course.currentOpen, medications.course.invalid, - medications.course.invalidRange, medications.course.notFound, medications.course.notRunning, - medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, - medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed, - medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType, - medications.ingredients.extract.rateLimited, medications.ingredients.invalid, - medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored, - medications.intake.bulk.invalid, medications.intake.bulk.too_large, - medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, - medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start, - medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module - — module.disabled; modules — modules.body.invalid_json, modules.invalid; mood — - mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, - mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, - mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; - notification-prefs — notification-prefs.body.invalid_json; notification — - notification.action.already_used, notification.action.expired, notification.action.invalid, - notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large, - nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; - onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, - onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, - onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, - passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — - practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, - profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; - profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, - profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; recovery — - recovery.kit.stale; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site; - route — route.retired; search — search.invalid; security_activity — security_activity.invalid; - share-link — share-link.document.decryptFailed, share-link.document.notFound, - share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, - share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; - sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, - sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, - sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — - symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, - symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, - tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, - vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, - vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, - workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) - — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + labs.create.invalid, labs.series.invalid, labs.link.invalid, labs.ocr.budgetExceeded, + labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, + labs.ocr.outputLimit, labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, + labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; + managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.handover.no_pending, + managed_profile.handover.unknown_guardian, managed_profile.not_found; maintenance — + maintenance.pulseDayMean.busy, maintenance.pulseDayMean.dryRunRequired; measurement — + measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large, + measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, + measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, + measurement.update.server_owned_source; medication — medication.intake.import.invalid_format, + medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid, + medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, + medications.course.invalid, medications.course.invalidRange, medications.course.notFound, + medications.course.notRunning, medications.course.oneShot, medications.course.overlap, + medications.course.windowRequired, medications.ingredients.extract.budgetExceeded, + medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge, + medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited, + medications.ingredients.invalid, medications.ingredients.unitMismatch, + medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, + medications.intake.bulk.too_large, medications.intake.force_slot.invalid, + medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, + medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound; + mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, + modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, + mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, + mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, + mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; + notification — notification.action.already_used, notification.action.expired, + notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid, + nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, + nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, + onboarding.complete.invalid, onboarding.complete.recordNotFound, + onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, + onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, + passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records — + personal_records.invalid_query; practitioner — practitioner.invalid; profile — + profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, + profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, + profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write — + record_write.rate_limited; recovery — recovery.kit.stale; report-selection — + report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search — + search.invalid; security_activity — security_activity.invalid; share-link — + share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, + symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; + timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token, + tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, + vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, + vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, + vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — + workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, + workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, + ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, @@ -40097,6 +40444,12 @@ components: $ref: "#/components/schemas/CycleVerdictDTO" stillLearning: type: boolean + projection: + type: array + items: + $ref: "#/components/schemas/ProjectedCycleDTO" + projectionState: + $ref: "#/components/schemas/CycleProjectionState" days: type: array items: @@ -40239,6 +40592,8 @@ components: - prediction - verdict - stillLearning + - projection + - projectionState - days - meta additionalProperties: false @@ -40422,6 +40777,53 @@ components: - FOLLICULAR - OVULATORY - LUTEAL + ProjectedCycleDTO: + type: object + properties: + k: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1 = the next period (the engine's own forecast), 2 = the one after it, … + start: + type: string + format: date + example: 2026-11-12 + description: The most likely first day. + periodLength: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Predicted bleeding days. + bandStart: + type: string + format: date + example: 2026-11-12 + description: The earliest likely first day. + bandEnd: + type: string + format: date + example: 2026-11-12 + description: The latest likely first day. + required: + - k + - start + - periodLength + - bandStart + - bandEnd + additionalProperties: false + description: A forecast period start with its band. The band widens from one cycle to the next and the list stops itself + where it would pass ±10 days or half a cycle. Render it as given; never extrapolate further cycles on a client. + CycleProjectionState: + type: string + enum: + - OK + - LEARNING + - LATE + - OFF + description: "Why `projection` holds what it holds. `OK`: the projected cycles. `LEARNING`: fewer than three observed + cycles, so the next period only (or nothing with no logged cycle). `LATE`: the next period's band has passed + with no new period logged; no forecast until one is. `OFF`: prediction is off or raw-chart mode is on." CycleHistoryEnvelope: type: object properties: @@ -40889,6 +41291,186 @@ components: - data - error additionalProperties: false + CycleFeedStatus: + type: object + properties: + data: + type: object + properties: + feed: + anyOf: + - $ref: "#/components/schemas/CycleFeedSummary" + - type: "null" + projectionState: + anyOf: + - $ref: "#/components/schemas/CycleProjectionState" + - type: "null" + fertileAvailable: + type: boolean + description: Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only). + required: + - feed + - projectionState + - fertileAvailable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedSummary: + type: object + properties: + tokenPrefix: + type: string + description: The first 8 characters of the link's token, to tell links apart. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + lastFetchedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + fetchCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lastFetcher: + anyOf: + - type: string + enum: + - GOOGLE + - APPLE + - MICROSOFT + - OTHER + - type: "null" + description: Which calendar app fetched last, as a family; never the raw User-Agent. + horizonCycles: + type: integer + minimum: 1 + maximum: 6 + historyCycles: + type: integer + minimum: 0 + maximum: 12 + includeFertile: + type: boolean + titleMode: + type: string + enum: + - DISCREET + - DESCRIPTIVE + required: + - tokenPrefix + - createdAt + - lastFetchedAt + - fetchCount + - lastFetcher + - horizonCycles + - historyCycles + - includeFertile + - titleMode + additionalProperties: false + description: "The live calendar subscription, without its secret: never the token hash and never the URL." + CycleFeedMinted: + type: object + properties: + data: + type: object + properties: + url: + type: string + description: The feed URL. Returned exactly once; it cannot be read back later. + webcalUrl: + type: string + description: The same URL with the `webcal:` scheme, which opens a calendar app's subscribe dialog. + feed: + $ref: "#/components/schemas/CycleFeedSummary" + required: + - url + - webcalUrl + - feed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedStatusAfterPatch: + type: object + properties: + data: + type: object + properties: + feed: + anyOf: + - $ref: "#/components/schemas/CycleFeedSummary" + - type: "null" + projectionState: + anyOf: + - $ref: "#/components/schemas/CycleProjectionState" + - type: "null" + fertileAvailable: + type: boolean + description: Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only). + required: + - feed + - projectionState + - fertileAvailable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedRevoked: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + const: true + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MeasurementCategoriesEnvelope: type: object properties: @@ -42115,6 +42697,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED value: type: number valueMin: @@ -42882,6 +43465,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - type: "null" biomarkerName: anyOf: @@ -45479,6 +46063,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -46678,6 +47263,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - type: "null" intervalDays: anyOf: @@ -46903,6 +47489,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED metricSlot: type: - string @@ -52576,8 +53163,8 @@ components: minimum: 0 maximum: 9007199254740991 description: Entries that wrote no workout row because the workout was already stored. The top-level counters count - workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was - attached. + workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series or + GPS track was attached. skipped: type: array items: @@ -52620,11 +53207,14 @@ components: - updated - skipped description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. - `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; - the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. - `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was - sent again with different values and the stored row now carries them; no row was inserted, so it also counts - towards `duplicates`. `skipped`: the server refused the entry, see `reason`." + `enriched`: the workout was already stored and the heart-rate series or GPS track this entry carried (or + both) was attached because the stored workout had none; no workout row was inserted, and the entry also + counts towards the top-level `duplicates`. A track is attached only while the account keeps tracks + (`storeWorkoutRoutes`), and when the stored workout had no elevation, the climb derived from the attached + track fills `elevationM`; no other column of the workout row is written. `updated` (since v1.40.0): a + workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different + values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. + `skipped`: the server refused the entry, see `reason`." reason: description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client restart, `invalid_samples` for a heart-rate series that failed validation." @@ -53214,6 +53804,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED date: type: string format: date @@ -53493,6 +54084,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED from: type: string format: date @@ -62225,6 +62817,7 @@ components: enum: - no_data - outside_window + - outside_reach - module_disabled - retrieval_failed - invalid_arguments @@ -62244,7 +62837,10 @@ components: additionalProperties: false description: "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: - never free text, an analyte name or a value." + never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window` + means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit + set for the Coach (the answer names the limit). A client without a string for a reason shows the step without + one." CoachStepDomain: type: string enum: @@ -62805,6 +63401,10 @@ components: - time - cap - no_progress + reason: + type: string + enum: + - outside_reach required: - id - phase @@ -62815,7 +63415,8 @@ components: additionalProperties: false description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET - /api/insights/chat/{id}/messages/{messageId}/trail`." + /api/insights/chat/{id}/messages/{messageId}/trail`. `reason` is set only on a settled `fetch` entry whose + readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for." CoachStop: type: object properties: @@ -63425,6 +64026,10 @@ components: - time - cap - no_progress + reason: + type: string + enum: + - outside_reach title: description: A screened reasoning title or checkpoint sentence. type: string @@ -74881,6 +75486,22 @@ components: - alphaDesc - manual description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it. + groupBy: + type: string + enum: + - none + - category + description: 'Whether the labs list shows its server-resolved categories (`category`) or one flat list (`none`). Default + "none". The labs list stays flat while `GET /api/labs/overview` answers `grouped: false`. The other lists + ignore it.' + groupsOpen: + type: string + enum: + - expanded + - flagged + - collapsed + description: 'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` + (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.' saved: type: boolean description: True when the server holds a choice for this list; false means every field is a default. @@ -74888,6 +75509,8 @@ components: - view - order - sortDir + - groupBy + - groupsOpen - saved additionalProperties: false ModuleListLayoutSavedEnvelope: @@ -76476,8 +77099,21 @@ components: type: array items: $ref: "#/components/schemas/LabOverviewMarker" + categories: + type: array + items: + $ref: "#/components/schemas/LabOverviewCategory" + description: "The list's categories in display order: catalog panels in catalog order, then the person's own panels by + label, then `other`." + grouped: + type: boolean + description: "Whether grouping adds anything: true when there are two categories or more and at least one is not + `other`. A client that shows categories (the `groupBy` list preference) shows a flat list while this is + false." required: - markers + - categories + - grouped additionalProperties: false LabOverviewMarker: type: object @@ -76506,6 +77142,66 @@ components: `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row. + LabOverviewCategory: + type: object + properties: + key: + type: string + label: + type: string + markerKeys: + type: array + items: + type: string + counts: + $ref: "#/components/schemas/LabOverviewCategoryCounts" + status: + type: string + enum: + - in-range + - below + - above + - unknown + required: + - key + - label + - markerKeys + - counts + - status + additionalProperties: false + description: "One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, + `custom:` for the person's own panel, or `other`; it carries no user text and is stable, so a client may + key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own + spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` + combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else + `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, + the order or the status." + LabOverviewCategoryCounts: + type: object + properties: + inRange: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + below: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + above: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + unknown: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - inRange + - below + - above + - unknown + additionalProperties: false + description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range). GetLabResultResponse: type: object properties: @@ -77530,8 +78226,16 @@ components: type: array items: $ref: "#/components/schemas/Biomarker" + customPanels: + type: array + items: + type: string + description: The caller's panels that are no catalog panel in any language, one spelling per folded form (the most + frequent, ties to the alphabetically first), sorted. Suggestions for the panel field; catalog panels are not + repeated here. required: - biomarkers + - customPanels additionalProperties: false Biomarker: type: object @@ -77859,6 +78563,185 @@ components: - data - error additionalProperties: false + BiomarkerSeriesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerSeries" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerSeries: + type: object + properties: + zoom: + type: string + enum: + - all + - year + - quarter + - range + range: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + dataFrom: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The local day of the marker's first numeric reading; null when it has none. `all` starts here. + required: + - from + - to + - dataFrom + additionalProperties: false + series: + $ref: "#/components/schemas/TimelineLabSeries" + required: + - zoom + - range + - series + additionalProperties: false + TimelineLabSeries: + type: object + properties: + key: + type: string + description: "`lab:`, as named in `values`." + biomarkerId: + type: string + name: + type: string + description: The marker's name, as the labs list shows it. + panel: + type: + - string + - "null" + unit: + type: string + description: The marker's own unit; every point is in it. + band: + anyOf: + - type: object + properties: + low: + type: + - number + - "null" + high: + type: + - number + - "null" + required: + - low + - high + additionalProperties: false + - type: "null" + description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest + reading in the window. Null when neither is known." + points: + type: array + items: + $ref: "#/components/schemas/TimelineLabPoint" + description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A + day with two readings has two points." + excluded: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` + rather than drawn on a scale they are not in. + required: + - key + - biomarkerId + - name + - panel + - unit + - band + - points + - excluded + additionalProperties: false + TimelineLabPoint: + type: object + properties: + id: + type: string + description: The lab result's id. + date: + type: string + description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. + value: + type: number + description: In the series' `unit`. + sourceValue: + description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision + `value` is shown with. Null for a reading stored as stated. + type: + - number + - "null" + rangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. + referenceLow: + description: "The window this reading is judged against, in the series' unit: the one its report printed, else the + marker's own." + type: + - number + - "null" + referenceHigh: + type: + - number + - "null" + report: + anyOf: + - type: object + properties: + id: + type: string + title: + description: The document's title, else its file name. + type: + - string + - "null" + href: + type: string + required: + - id + - title + - href + additionalProperties: false + - type: "null" + description: The document the reading was read from, when one is linked and the caller may see documents. + required: + - id + - date + - value + - sourceValue + - rangeStatus + - referenceLow + - referenceHigh + - report + additionalProperties: false MergeBiomarkersResponse: type: object properties: @@ -77934,6 +78817,13 @@ components: items: type: string description: Names that become aliases of the target. + adoptedPanel: + description: "The panel the target takes over: when the target has none, the first panel a source carries, in the order + the sources were given. `null` when the target keeps its own panel or no source has one. Reported on a dry + run too, while `target.panel` there is still the stored one." + type: + - string + - "null" mergeId: description: "`null` on a dry run." type: @@ -77946,6 +78836,7 @@ components: - movedReadings - convertedReadings - aliasesAdded + - adoptedPanel - mergeId additionalProperties: false description: What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's @@ -78202,7 +79093,8 @@ components: from: type: string description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before - today on `default`." + today on `default`. Never more than 730 days back from `to`, both days counted: the longest window the + health-report export covers (see `capped`)." to: type: string description: The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A @@ -78222,11 +79114,17 @@ components: anyOf: - $ref: "#/components/schemas/EncounterPreparationAnchor" - type: "null" + capped: + type: boolean + description: True when `since` lies further back than a health report covers, so `from` is the first day of the last 730 + days ending on `to` instead of the day of `since`. A client says the window covers the last two years rather + than everything since that visit; a report for `from` to `to` is the one the export builds. required: - from - to - basis - since + - capped additionalProperties: false description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology @@ -82827,6 +83725,7 @@ components: - oura - whoop - polar + - garmin - fitbit - strava - nightscout @@ -83481,6 +84380,15 @@ components: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" + acceptedTypes: + description: The HealthKit quantity and category identifiers `POST /api/measurements/batch` maps today (e.g. + `HKQuantityTypeIdentifierBodyMass`, `HKCategoryTypeIdentifierSleepAnalysis`), sorted. Derived from the + server's mapping table, so a type the server defers is never listed. A client requests and sends only types + in this list and its own table; when the field is absent (an older server) it keeps its own table. Present + on the GET read; omitted from the PATCH echo. + type: array + items: + type: string required: - entries - lastSyncedAt @@ -83547,14 +84455,20 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, - since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or - skip a type they do not know rather than fail. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period + days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for + medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than + fail. lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Newest recorded reading for this type from this source. + description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC. + count: + description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only. + type: integer + minimum: 0 + maximum: 9007199254740991 stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single @@ -86190,14 +87104,20 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, - since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or - skip a type they do not know rather than fail. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period + days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for + medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than + fail. lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Newest recorded reading for this type from this source. + description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC. + count: + description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only. + type: integer + minimum: 0 + maximum: 9007199254740991 stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single @@ -93482,6 +94402,19 @@ components: $ref: "#/components/schemas/TimelineLabMarker" description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`. + symptomSeries: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomSeries" + description: The person-defined symptoms named in `values`, in the order named, each averaged per `bucket` over the days + it was logged. A symptom named but not found in the record is left out. + availableSymptoms: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomOption" + description: Every person-defined symptom this record has logged at least once, hidden ones included (their history is + kept), in the record's own order, when the caller may see illness data. Each may be named in `values` by its + `key`. notable: type: array items: @@ -93511,6 +94444,8 @@ components: - availableSeries - labSeries - availableLabs + - symptomSeries + - availableSymptoms - notable additionalProperties: false TimelineItem: @@ -93663,131 +94598,6 @@ components: - unit - points additionalProperties: false - TimelineLabSeries: - type: object - properties: - key: - type: string - description: "`lab:`, as named in `values`." - biomarkerId: - type: string - name: - type: string - description: The marker's name, as the labs list shows it. - panel: - type: - - string - - "null" - unit: - type: string - description: The marker's own unit; every point is in it. - band: - anyOf: - - type: object - properties: - low: - type: - - number - - "null" - high: - type: - - number - - "null" - required: - - low - - high - additionalProperties: false - - type: "null" - description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest - reading in the window. Null when neither is known." - points: - type: array - items: - $ref: "#/components/schemas/TimelineLabPoint" - description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A - day with two readings has two points." - excluded: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` - rather than drawn on a scale they are not in. - required: - - key - - biomarkerId - - name - - panel - - unit - - band - - points - - excluded - additionalProperties: false - TimelineLabPoint: - type: object - properties: - id: - type: string - description: The lab result's id. - date: - type: string - description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. - value: - type: number - description: In the series' `unit`. - sourceValue: - description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision - `value` is shown with. Null for a reading stored as stated. - type: - - number - - "null" - rangeStatus: - type: string - enum: - - in-range - - below - - above - - unknown - description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. - referenceLow: - description: "The window this reading is judged against, in the series' unit: the one its report printed, else the - marker's own." - type: - - number - - "null" - referenceHigh: - type: - - number - - "null" - report: - anyOf: - - type: object - properties: - id: - type: string - title: - description: The document's title, else its file name. - type: - - string - - "null" - href: - type: string - required: - - id - - title - - href - additionalProperties: false - - type: "null" - description: The document the reading was read from, when one is linked and the caller may see documents. - required: - - id - - date - - value - - sourceValue - - rangeStatus - - referenceLow - - referenceHigh - - report - additionalProperties: false TimelineLabMarker: type: object properties: @@ -93812,6 +94622,104 @@ components: - panel - unit additionalProperties: false + TimelineSymptomSeries: + type: object + properties: + key: + type: string + description: "`symptom:`, as named in `values`." + definitionId: + type: string + label: + description: The person's own name for the symptom. Null when it cannot be read; the client words a stand-in. + type: + - string + - "null" + icon: + description: The symptom's icon name; null for the default glyph. + type: + - string + - "null" + isActive: + type: boolean + description: "False for a hidden symptom: no longer offered for entry, its history kept and drawn." + scale: + type: object + properties: + min: + type: number + const: 0 + max: + type: number + const: 10 + required: + - min + - max + additionalProperties: false + description: The fixed scale the line is drawn on, the intensity scale, whatever the window holds. + points: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomPoint" + description: Buckets of the response's `bucket` with at least one logged day, oldest first. A bucket without one is + absent, never zero and never interpolated. + required: + - key + - definitionId + - label + - icon + - isActive + - scale + - points + additionalProperties: false + TimelineSymptomPoint: + type: object + properties: + t: + type: string + description: The bucket's first day. + mean: + type: number + minimum: 0 + maximum: 10 + description: The mean of the bucket's day values over the days the symptom was logged. A day's value is the highest + intensity (0 to 10) logged on it; a day without an entry is not a zero and does not count. + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than + three as thin. + required: + - t + - mean + - count + additionalProperties: false + TimelineSymptomOption: + type: object + properties: + key: + type: string + description: "`symptom:`, to name in `values`." + definitionId: + type: string + label: + type: + - string + - "null" + icon: + type: + - string + - "null" + isActive: + type: boolean + required: + - key + - definitionId + - label + - icon + - isActive + additionalProperties: false TimelineReadinessEnvelope: type: object properties: diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index af378a9d3..65d4c11b7 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -194,6 +194,7 @@ hand-authored. | `WALKING_ASYMMETRY` | percent (0–100) | | `WALKING_DOUBLE_SUPPORT` | percent (0–100) | | `ACTIVE_ENERGY_BURNED` | kcal | +| `BASAL_ENERGY_BURNED` | kcal | | `ENERGY_EXPENDITURE_KJ` | kJ | | `VO2_MAX` | mL/(kg·min) | | `PULSE_WAVE_VELOCITY` | m/s | diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index fd7570fd2..039ecfd43 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -72,11 +72,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env ``` -| Variable | Purpose | Rotation cost | -| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | -| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | -| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session | +| Variable | Purpose | Rotation cost | +| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | +| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | +| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token, share link and cycle calendar-feed link, and signs out every browser session | You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml` stack: compose constructs the connection string inside the `app` @@ -289,8 +289,8 @@ this server needs: retired id for as long as a stored or off-host backup copy still needs it; - `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when off-host copies are set up; -- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends - (no data is lost). +- `API_TOKEN_HMAC_KEY`, without which every session, token, share link and + calendar-feed link ends (no data is lost). Each key is shown by id and a fingerprint (the first 12 hex characters of SHA-256 over the key), never the key itself, and **Check my copy** compares a diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 1fcaec46a..1639f9890 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -121,6 +121,27 @@ the grace window to 0 or wait it out, remove the old volume, and let the dumps age out or delete them ([backup-restore.md](../ops/backup-restore.md)). +## Calendar subscription + +The cycle settings can publish your forecast as a calendar feed that Google +Calendar, Apple Calendar or Outlook subscribes to. It is off by default, and +turning it on changes the arrangement this page describes: from then on the +feed's dates also sit with that calendar provider, on its servers, under its +policies. That is exactly the cloud vendor self-hosting otherwise keeps out of +the picture. + +What the feed carries is deliberately small. Each event is an all-day date +range and a title, nothing else: no symptoms, notes, flow, temperatures, test +results, contraception or intercourse fields. By default the title is a dot +and the calendar is named "HealthLog", so a glance at a shared screen says +nothing; past cycles are left out unless you ask for them, and the fertile +window is only offered when your goal is trying to conceive. + +Turning the subscription off, or rotating its link, stops the provider's next +poll. It does not reach the copies the provider already fetched: remove the +subscribed calendar there as well. The link itself is a credential; see +[keeping it out of access logs](./reverse-proxy.md#credentials-in-the-url-path-keep-them-out-of-access-logs). + ## Practical hardening If reproductive-health privacy is the reason you self-host, the diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 3cceebaac..fefca74b2 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -336,6 +336,49 @@ A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client address at all (requests keep working) and is named in the `[proxy-trust]` boot line. +## Credentials in the URL path: keep them out of access logs + +A few HealthLog URLs carry their credential in the path, because the caller +cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`, +polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`) +and the provider webhooks. HealthLog scrubs these segments from its own logs +and error reports. It cannot reach your proxy's access log, which records the +full path of every request by default, so anyone who can read that log can +read a live feed link. + +Either drop those requests from the access log or rewrite the path before it +is written. For Caddy, skip logging the token-bearing paths: + +```caddyfile +your-instance.example.com { + @credential_paths path /api/cycle/feed/* /c/* + log_skip @credential_paths + reverse_proxy app:3000 +} +``` + +For Nginx, log a redacted path instead of `$request`: + +```nginx +map $request_uri $redacted_uri { + ~^/api/cycle/feed/ /api/cycle/feed/[REDACTED]; + ~^/c/ /c/[REDACTED]; + default $request_uri; +} +log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" ' + '$status $body_bytes_sent "$http_user_agent"'; +access_log /var/log/nginx/access.log redacted; +``` + +Traefik's access log has no per-path filter; turn `accessLog` off, or keep it +on a host only you can read. If a feed link has been in a log you do not +control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate +link): the old one stops working on the provider's next poll. + +The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a +caching proxy in front must pass `If-None-Match` through rather than cache the +body for everyone. + ## Running over plain HTTP on a LAN (no reverse proxy) The session cookie carries the `Secure` flag by default under diff --git a/docs/using/cycle-calendar-feed.md b/docs/using/cycle-calendar-feed.md new file mode 100644 index 000000000..b05c53d66 --- /dev/null +++ b/docs/using/cycle-calendar-feed.md @@ -0,0 +1,55 @@ +# Subscribing to your cycle forecast + +HealthLog can publish your cycle forecast as a calendar subscription, so the +next few periods show up in the calendar app you already use. The feed is +computed fresh every time your calendar app asks for it; HealthLog stays the +only place the data is kept. + +## Turning it on + +Open **Cycle**, then **Settings**, and find **Calendar subscription**. Before +the button, the card says what it means: your calendar provider stores the +dates on its servers, and turning the subscription off later does not remove +copies it already holds. HealthLog asks you to confirm it is you (a password or +passkey) and then shows the link once. Copy it, or use **Open in calendar app**. + +Only you can turn it on for your own record. For a managed profile (a child's +record, for instance), its guardian can. Someone you have shared your record +with cannot, whatever level of access you gave them: the feed puts your dates +in their calendar provider's cloud, and that is your decision alone. It is +managed on the web; the iOS app shows where to find it. + +## Adding it to your calendar + +- **Google Calendar:** Other calendars, From URL, paste the link. Google + refreshes subscriptions on its own schedule, roughly every 12 to 24 hours. +- **Apple Calendar:** on a Mac, File, New Calendar Subscription. On iPhone, + Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar. + Apple lets you choose how often it refreshes. +- **Outlook:** Add calendar, Subscribe from web, paste the link. Outlook + refreshes every few hours. + +## What the feed shows + +- The next period and, once three cycles are logged, a few after it (three + by default, up to six). Each event covers the whole window the period may + fall in, and the window widens the further out it is. The list stops itself + where a date would be more guess than estimate (beyond about ±10 days, or + half a cycle). +- Optionally, your past cycles (up to twelve). +- Optionally, when your goal is trying to conceive, the estimated fertile + window of the next cycle. It is an estimate, not a contraceptive method. + +Titles are discreet by default: each event is a dot and the calendar is called +"HealthLog". Descriptive titles name the period instead. Events never carry +symptoms, notes or any other detail of your log. + +When a period is later than its window, the feed shows no forecast until you +log it, instead of guessing a new date. With predictions switched off, only +past cycles appear (if you chose to include them). + +## Rotating and turning off + +**Rotate link** replaces the link; calendars using the old one stop updating. +**Turn off** ends the link. In both cases, remove the old subscribed calendar +in your calendar app too: what it already fetched stays there until you do. diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 6516ea389..8393384cc 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -820,6 +820,49 @@ test.describe("document vault", () => { await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible(); }); + test("a busy server is waited out and the file still lands", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "runs once on the desktop project (shared bucket)"); + // The route answers the first POST the way a full upload slot does; the + // queue must retry it after Retry-After instead of showing an error. + let refused = 0; + await page.route("**/api/documents/inbound", async (route) => { + if (route.request().method() === "POST" && refused === 0) { + refused += 1; + await route.fulfill({ + status: 429, + headers: { "Retry-After": "1", "Content-Type": "application/json" }, + body: JSON.stringify({ + data: null, + error: "Too many uploads are already in progress.", + meta: { + errorCode: "documents.inbound.uploadBusy", + reason: "uploadBusy", + }, + }), + }); + return; + } + await route.fallback(); + }); + await page.goto("/documents"); + const marker = `busy-probe-${Date.now()}`; + + await uploadViaPicker(page, `${marker}.pdf`, uniquePdf(marker)); + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ + timeout: 15_000, + }); + expect(refused).toBe(1); + await expect( + page.locator('[data-slot="document-upload-card"]'), + ).toHaveCount(0); + }); + // ── Admin cap: commit-on-Enter + rejection copy naming the limit ────── test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({ diff --git a/e2e/labs-list-groups.spec.ts b/e2e/labs-list-groups.spec.ts new file mode 100644 index 000000000..360224e7b --- /dev/null +++ b/e2e/labs-list-groups.spec.ts @@ -0,0 +1,104 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + expectNoHorizontalOverflow, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * The labs list under its categories. + * + * The account is the one `globalSetup` seeds for the mobile route sweep: one + * marker under its own panel and one qualitative reading without a panel, so + * the server answers two categories and `grouped: true`. The grouping choice + * is served to this page only (the layout read is answered in the browser + * with the account's own layout plus `groupBy: "category"`), so the shared + * account's stored preferences stay as other specs expect them. + */ + +const LAYOUT = "**/api/settings/module-list-layout"; +const GROUP = '[data-slot="lab-list-group"]'; + +async function serveGrouped(page: Page, groupsOpen = "expanded") { + await page.route(LAYOUT, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + const body = (await response.json()) as { + data: { lists: { labs: Record } }; + }; + body.data.lists.labs = { + ...body.data.lists.labs, + groupBy: "category", + groupsOpen, + saved: true, + }; + await route.fulfill({ response, json: body }); + }); +} + +test.describe("the lab list under its categories", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + test("shows one disclosure per category and folds a category away", async ({ + page, + }) => { + await serveGrouped(page); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const headers = page.locator(GROUP); + await expect(headers).toHaveCount(2); + await expect(headers.first()).toHaveAttribute("aria-expanded", "true"); + + const names = page.locator('[data-slot="lab-list-analyte"]'); + const before = await names.count(); + expect(before).toBeGreaterThan(1); + + await headers.first().click(); + await expect(headers.first()).toHaveAttribute("aria-expanded", "false"); + await expect(names).toHaveCount(before - 1); + + // The choice holds across a reload in this browser. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(GROUP).first()).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("starts with every category closed when the person chose so", async ({ + page, + }) => { + await serveGrouped(page, "collapsed"); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const headers = page.locator(GROUP); + await expect(headers).toHaveCount(2); + for (const header of await headers.all()) { + await expect(header).toHaveAttribute("aria-expanded", "false"); + } + await expect(page.locator('[data-slot="lab-list-analyte"]')).toHaveCount(0); + }); + + test("fits a phone and passes axe", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await serveGrouped(page); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + await expect(page.locator(GROUP)).toHaveCount(2); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, "/labs grouped at 390 px"); + + const results = await new AxeBuilder({ page }) + .include('[data-slot="lab-list"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations.map((v) => v.id)).toEqual([]); + }); +}); diff --git a/e2e/life-circumstances-typing.spec.ts b/e2e/life-circumstances-typing.spec.ts new file mode 100644 index 000000000..f0fc05069 --- /dev/null +++ b/e2e/life-circumstances-typing.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The tobacco section's typed fields take what a person types one key at a + * time. Through v1.47.0 neither did: "When you stopped" only accepted text + * that was already a whole YYYY-MM month, so every keystroke was thrown away + * and only a paste got in, and the start year clamped each keystroke into + * 1900..this year, so the first digit became 1900. + * + * The test types and leaves the fields without saving, so the shared + * account keeps whatever answers it had. + */ +test.describe("life circumstances typed fields", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("start year and quit month take keystrokes", async ({ page }) => { + await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" }); + const section = page.locator('[data-slot="life-section-tobacco"]'); + await section.locator("summary").click(); + + await section.locator("#life-tobacco-status").selectOption("FORMER"); + + const start = section.locator("#life-tobacco-start"); + await start.fill(""); + await start.pressSequentially("1985"); + await expect(start).toHaveValue("1985"); + await start.blur(); + await expect(start).toHaveValue("1985"); + + const quit = section.locator("#life-tobacco-quit"); + await quit.fill(""); + await quit.pressSequentially("2019-03"); + await expect(quit).toHaveValue("2019-03"); + await quit.blur(); + await expect(quit).toHaveValue("2019-03"); + + // A single-digit month is padded when the field is left. + await quit.fill(""); + await quit.pressSequentially("2018-7"); + await expect(quit).toHaveValue("2018-7"); + await quit.blur(); + await expect(quit).toHaveValue("2018-07"); + }); +}); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index ad5f3d0c7..422ce9e09 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -18,6 +18,9 @@ * 7. A lab marker (#1299) is picked from the values menu, drawn as its * single readings with the range behind them, read on its own, kept * across a reload, and listed on its lab day on a phone. + * 8. A symptom (#981) is picked from the values menu, drawn as a value + * line on the fixed 0 to 10 scale, read with the days behind a point, + * kept across a reload, and named in the phone chronicle. * * The timeline's server routes are mocked with the contract's shapes * (`utils/mock-timeline.ts`); the module switch is the real one. Every @@ -866,13 +869,18 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept", '[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]', ); const tip = page.locator('[data-slot="timeline-point-tip"]'); - // A CI run focused the target and no tip opened. The cause is not pinned - // down (the tips layer unmounts while the chart has no layout, which is - // one candidate); focus again until the tip opens. - await expect(async () => { - await target.focus(); - await expect(tip).toHaveAttribute("data-t", "fe-1", { timeout: 1_000 }); - }).toPass({ timeout: 10_000 }); + // A pointer resting on another line's point, as the menu click can leave + // it: the focus takes the card, and that point losing the pointer (the + // focus scrolls the chart from under it) must not close the card. + await page + .locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]') + .last() + .hover(); + await expect(tip).toHaveAttribute("data-series", "WEIGHT"); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", "fe-1"); + await page.mouse.move(0, 0); + await expect(tip).toHaveAttribute("data-t", "fe-1"); await expect(tip).toContainText("22 ng/mL"); await expect(tip).toContainText("Laborbefund März"); @@ -914,6 +922,86 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept", await expect(labs).toHaveCount(0); }); +test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY, { symptoms: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/timeline?zoom=all"); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({ + timeout: 20_000, + }); + + await page + .locator( + '[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]', + ) + .click(); + const menu = page.locator('[data-slot="timeline-values-menu"]'); + await expect( + menu.locator('[data-slot="timeline-values-symptoms"]'), + ).toBeVisible(); + // A symptom hidden from entry is still offered, and says so. + await expect( + menu.locator( + '[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]', + ), + ).toBeVisible(); + await menu + .locator( + '[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]', + ) + .click(); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + + const row = page.locator('g[data-series="symptom:sd-headache"]'); + const points = row.locator('[data-slot="timeline-series-point"]'); + await expect(points).toHaveCount(3); + // The quarter resting on two days is hollow. + await expect(points.nth(1)).toHaveAttribute("data-thin", "true"); + await expect( + page.locator('[data-slot="timeline-legend-symptom"]'), + ).toBeVisible(); + expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain( + "symptom:sd-headache", + ); + + const target = page.locator( + '[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]', + ); + const tip = page.locator('[data-slot="timeline-point-tip"]'); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", "2024-01-01"); + // Out of 10 in the account's own decimal mark, with the days behind it. + await expect(tip).toContainText(/6[.,]5\/10/); + await expect(tip).toContainText(/\b9\b/); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="timeline-page"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); + + // The choice is kept: a reload asks for the symptom again. + await page.reload(); + await expect(points).toHaveCount(3, { timeout: 20_000 }); + + // On a phone: its means in the chronicle, no sideways scroll. + await page.setViewportSize({ width: 390, height: 844 }); + const means = page.locator( + '[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]', + ); + await expect(means.first()).toBeVisible(); + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +}); + test("the timeline page ends where its content does, at every desktop width", async ({ page, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index f0c905129..ef2284a41 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -514,6 +514,8 @@ export function fullTimeline(today: string) { availableSeries: FULL_AVAILABLE, labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } @@ -610,6 +612,50 @@ function labsLane(keys: readonly string[]) { }; } +/** + * Two of the person's own symptoms (#981): a headache logged over several + * quarters, one quarter resting on two days, and a nausea hidden from entry + * since, logged once. Their points are bucket means of day peaks on the + * fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all` + * view of the full record uses. + */ +export const SYMPTOM_OPTIONS = [ + { + key: "symptom:sd-headache", + definitionId: "sd-headache", + label: "Kopfschmerz", + icon: "brain", + isActive: true, + }, + { + key: "symptom:sd-nausea", + definitionId: "sd-nausea", + label: "Übelkeit", + icon: null, + isActive: false, + }, +]; + +const SYMPTOM_QUARTERS: Record< + string, + Array<{ t: string; mean: number; count: number }> +> = { + "symptom:sd-headache": [ + { t: "2024-01-01", mean: 6.5, count: 9 }, + { t: "2024-04-01", mean: 4, count: 2 }, + { t: "2025-01-01", mean: 3.25, count: 5 }, + ], + "symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }], +}; + +function symptomLines(keys: readonly string[], bucket: Bucket) { + return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({ + ...o, + scale: { min: 0, max: 10 }, + points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [], + })); +} + export function readyTimeline(today: string) { return { zoom: "all", @@ -733,6 +779,8 @@ export function readyTimeline(today: string) { availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [], }; } @@ -935,6 +983,8 @@ export async function mockTimeline( dataFrom?: string; /** The record has lab markers to offer and draw (#1299). */ labs?: boolean; + /** The record has logged symptoms to offer and draw (#981). */ + symptoms?: boolean; } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; @@ -950,7 +1000,11 @@ export async function mockTimeline( .filter(Boolean); // A lab marker is a line of its own (`labSeries`), never a mean. const labKeys = named.filter((key) => key.startsWith("lab:")); - const values = named.filter((key) => !key.startsWith("lab:")); + // A symptom is a line of its own too (`symptomSeries`), on 0 to 10. + const symptomKeys = named.filter((key) => key.startsWith("symptom:")); + const values = named.filter( + (key) => !key.startsWith("lab:") && !key.startsWith("symptom:"), + ); const labs = state === "full" && options.labs ? { @@ -966,6 +1020,13 @@ export async function mockTimeline( const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); + const symptoms = + state === "full" && options.symptoms + ? { + symptomSeries: symptomLines(symptomKeys, bucket), + availableSymptoms: SYMPTOM_OPTIONS, + } + : {}; const dataFrom = options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01"); if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { @@ -988,6 +1049,7 @@ export async function mockTimeline( return json(route, 200, { ...body, ...labs, + ...symptoms, zoom, bucket, ...(chosen diff --git a/messages/de.json b/messages/de.json index 5abe7c515..6d0139ccd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Herzfrequenzvariabilität", "typeRestingHeartRate": "Ruheherzfrequenz", "typeActiveEnergyBurned": "Aktive Energie", + "typeBasalEnergyBurned": "Ruheenergie", "typeFlightsClimbed": "Treppen", "typeWalkingRunningDistance": "Geh- und Laufstrecke", "typeVo2Max": "VO₂max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Wird von der iOS-App synchronisiert oder aus einem Export importiert.", - "file": "Wird unter Einstellungen importiert, sobald du hier fertig bist." + "file": "Wird unter Einstellungen importiert, sobald du hier fertig bist.", + "fitbit": "Wird jetzt über Google Health verbunden." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Noch nicht verbunden.", "openSettings": "Einstellungen → Integrationen öffnen", "noDate": "unbekannt", + "throughGoogleHealth": "{source}-Daten kommen jetzt über {target}: Die klassische {source}-Verbindung wurde abgeschaltet. Verbinde {target} mit dem Google-Konto, an dem dein {source} angemeldet ist, dann kommen deine Werte von dort.", "needsCredentials": { "title": "Diese Instanz hat noch keine {target}-App hinterlegt.", "detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten." @@ -6903,7 +6907,9 @@ "summary": "{count} Datentypen, neuestes {relative}", "quiet": "{count} still", "workouts": "Workouts", - "medicationDoses": "Medikamenteneinnahmen" + "medicationDoses": "Medikamenteneinnahmen", + "cycleDays": "Zyklustage", + "cycleDaysCount": "Zyklustage ({count})" }, "integrationFallback": { "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", "endedPeriodOnDate": "Periode an diesem Tag beendet", "startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.", - "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da." + "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da.", + "feed": { + "title": "Kalender-Abo", + "description": "Deine nächsten Perioden als ganztägige Termine in der Kalender-App, die du schon nutzt.", + "disclosure": "Dein Kalenderanbieter (Google, iCloud, Outlook) speichert diese Daten auf seinen Servern. Schaltest du das Abo später ab, bleiben Kopien, die er schon hat, dort liegen.", + "turnOn": "Abo einschalten", + "error": "Das Kalender-Abo konnte nicht geändert werden. Versuch es noch einmal.", + "linkHidden": "Ein Link, der mit {prefix} beginnt, ist aktiv. Er wird nur einmal gezeigt, beim Erstellen; erneuere ihn, wenn du einen neuen brauchst.", + "linkLabel": "Abo-Link", + "linkOnce": "Nur jetzt sichtbar. Füge ihn in deine Kalender-App ein oder erneuere ihn später für einen neuen.", + "copy": "Link kopieren", + "copied": "Link kopiert.", + "copyFailed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.", + "openInCalendar": "In Kalender-App öffnen", + "notFetched": "Noch nicht abgerufen.", + "lastFetched": "Zuletzt {when} von {fetcher} abgerufen.", + "fetcher": { + "GOOGLE": "Google Kalender", + "APPLE": "Apple Kalender", + "MICROSOFT": "Outlook", + "OTHER": "einer Kalender-App" + }, + "horizon": "Zyklen voraus", + "history": "Vergangene Zyklen", + "historyNone": "Keine", + "titles": "Termintitel", + "titleMode": { + "DISCREET": "Diskret (●)", + "DESCRIPTIVE": "Beschreibend" + }, + "fertile": "Fruchtbares Fenster", + "fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.", + "rotate": "Link erneuern", + "rotateTitle": "Link erneuern?", + "rotateBody": "Kalender mit dem alten Link werden nicht mehr aktualisiert. Du bekommst einen neuen Link, den du stattdessen einträgst.", + "turnOff": "Ausschalten", + "turnOffTitle": "Kalender-Abo ausschalten?", + "turnOffBody": "Der Link funktioniert nicht mehr. Kopien bei deinem Kalenderanbieter bleiben, bis du den Kalender dort entfernst.", + "help": { + "title": "So trägst du es ein", + "google": "Google Kalender: Weitere Kalender, Per URL, dann den Link einfügen. Google aktualisiert etwa alle 12 bis 24 Stunden.", + "apple": "Apple Kalender: am Mac Ablage, Neues Kalenderabonnement; am iPhone Einstellungen, Kalender, Accounts, Account hinzufügen, Andere, Kalenderabo hinzufügen.", + "outlook": "Outlook: Kalender hinzufügen, Aus dem Internet abonnieren, dann den Link einfügen. Outlook aktualisiert alle paar Stunden." + }, + "ics": { + "calendarName": "Zyklus (HealthLog)", + "periodEstimate": "Periode (Schätzung)", + "periodLogged": "Periode (erfasst)", + "fertileEstimate": "Fruchtbares Fenster (Schätzung)", + "likelyStart": "Wahrscheinlicher Beginn {date}, {band}", + "bandOne": "±{count} Tag", + "bandFew": "±{count} Tage", + "bandOther": "±{count} Tage" + } + }, + "projection": { + "title": "Spätere Zyklen", + "around": "Um den {date}", + "state": { + "LEARNING": "Bis 3 Zyklen erfasst sind, ist nur die nächste Periode enthalten.", + "LATE": "Keine Prognose, solange diese Periode später kommt als erwartet; sie ist zurück, sobald du sie erfasst.", + "OFF": "Prognosen sind ausgeschaltet; enthalten sind nur vergangene Zyklen, falls gewählt." + } + } }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.", "panel": "Panel", "panelPlaceholder": "z. B. Lipide", + "panelHint": "Wähle einen Vorschlag oder gib ein eigenes ein. Panels aus dem Katalog werden in jeder Sprache erkannt.", "lowerBound": "Referenz unten", "upperBound": "Referenz oben", "lowerPlaceholder": "Untere Grenze", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", "notLinkedYet": "Noch keinem Biomarker zugeordnet", + "groups": { + "other": "Sonstige", + "allInRange": "Alle im Bereich", + "outsideCountOne": "{count} von {total} außerhalb des Bereichs", + "outsideCountFew": "{count} von {total} außerhalb des Bereichs", + "outsideCountOther": "{count} von {total} außerhalb des Bereichs", + "heading": "Kategorien", + "description": "Gruppiert die Werte nach Panel, etwa Lipide oder Leber, mit einem Status je Gruppe.", + "label": "Gruppierung", + "none": "Keine", + "category": "Nach Kategorie", + "openLabel": "Beim Öffnen aufgeklappt", + "openExpanded": "Alle", + "openFlagged": "Nur außerhalb des Bereichs", + "openCollapsed": "Keine", + "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie." + }, "sourceRange": { "fromReport": "Befundbereich {range}", "catalogDiffers": "dein Bereich {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", "refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.", "aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:", + "adoptsPanel": "Der behaltene Biomarker übernimmt das Panel {panel}.", "blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.", "noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.", "doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben", @@ -12005,7 +12093,8 @@ "unsupportedType": "Dieser Dateityp wird nicht unterstützt.", "purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.", "duplicateExists": "Ein identisches Dokument ist bereits gespeichert.", - "rateLimited": "Zu viele Uploads. Versuche es in ein paar Minuten erneut.", + "uploadBusy": "Der Server war mit anderen Uploads beschäftigt. Versuche es jetzt erneut.", + "rateLimited": "Das Upload-Limit für diese Stunde ist erreicht. Versuche es später erneut.", "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} hat Biomarker zusammengeführt", "medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert", "auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt", - "auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht" + "auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht", + "cycleFeedCreate": "{name} hat das Kalender-Abo eingeschaltet", + "cycleFeedUpdate": "{name} hat das Kalender-Abo geändert", + "cycleFeedRotate": "{name} hat den Link des Kalender-Abos erneuert", + "cycleFeedRevoke": "{name} hat das Kalender-Abo ausgeschaltet" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Seit deinem letzten Termin ({specialty}) am {date}", "kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}", "default": "Die letzten {days} Tage", - "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art." + "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.", + "capped": "Die letzten zwei Jahre", + "cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Laborwert, wie gemessen", "legendLabOutside": "Außerhalb des Referenzbereichs", - "legendLabRange": "Referenzbereich" + "legendLabRange": "Referenzbereich", + "legendSymptom": "Symptomlinie: höchste Stärke jedes erfassten Tages (0 bis 10), gemittelt über die erfassten Tage", + "symptoms": { + "menuTitle": "Symptome", + "full": "Bis zu {max} Symptome gleichzeitig. Entferne eines, um ein anderes hinzuzufügen.", + "hidden": "ausgeblendet", + "unnamed": "Symptom", + "daysOne": "an 1 Tag", + "daysFew": "Schnitt aus {count} Tagen", + "daysOther": "Schnitt aus {count} Tagen" + } }, "lifeEvents": { "title": "Lebensereignis", diff --git a/messages/en.json b/messages/en.json index 4160fe8fd..9e86a3f69 100644 --- a/messages/en.json +++ b/messages/en.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Heart-rate variability", "typeRestingHeartRate": "Resting heart rate", "typeActiveEnergyBurned": "Active energy", + "typeBasalEnergyBurned": "Resting energy", "typeFlightsClimbed": "Flights climbed", "typeWalkingRunningDistance": "Walking + running distance", "typeVo2Max": "VO₂ max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Synced by the iOS app, or imported from an export.", - "file": "Imported under Settings once you are done here." + "file": "Imported under Settings once you are done here.", + "fitbit": "Connected through Google Health now." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Not connected yet.", "openSettings": "Open Settings → Integrations", "noDate": "unknown", + "throughGoogleHealth": "{source} data now comes through {target}: the classic {source} connection has been shut down. Connect {target} with the Google account your {source} is signed in to, and your readings arrive from there.", "needsCredentials": { "title": "This instance has no {target} app on file yet.", "detail": "Enter your own credentials under Settings → Integrations, then the connection can start." @@ -6903,7 +6907,9 @@ "summary": "{count} data types, newest {relative}", "quiet": "{count} quiet", "workouts": "Workouts", - "medicationDoses": "Medication doses" + "medicationDoses": "Medication doses", + "cycleDays": "Cycle days", + "cycleDaysCount": "Cycle days ({count})" }, "integrationFallback": { "description": "Configured on this account without a settings card of its own." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "My period started this day", "endedPeriodOnDate": "My period ended this day", "startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.", - "startRestored": "Saved. The start on {dates} is back." + "startRestored": "Saved. The start on {dates} is back.", + "feed": { + "title": "Calendar subscription", + "description": "Your next periods as all-day events in the calendar app you already use.", + "disclosure": "Your calendar provider (Google, iCloud, Outlook) stores these dates on its servers. Turning this off later does not remove copies it already holds.", + "turnOn": "Turn on subscription", + "error": "The calendar subscription could not be changed. Try again.", + "linkHidden": "A link starting with {prefix} is active. It is shown only once, when it is made; rotate it to get a new one.", + "linkLabel": "Subscription link", + "linkOnce": "Shown only now. Paste it into your calendar app, or rotate it later for a new one.", + "copy": "Copy link", + "copied": "Link copied.", + "copyFailed": "Copying did not work. Select the link and copy it by hand.", + "openInCalendar": "Open in calendar app", + "notFetched": "Not fetched yet.", + "lastFetched": "Last fetched {when} by {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Apple Calendar", + "MICROSOFT": "Outlook", + "OTHER": "a calendar app" + }, + "horizon": "Cycles ahead", + "history": "Past cycles", + "historyNone": "None", + "titles": "Event titles", + "titleMode": { + "DISCREET": "Discreet (●)", + "DESCRIPTIVE": "Descriptive" + }, + "fertile": "Fertile window", + "fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.", + "rotate": "Rotate link", + "rotateTitle": "Rotate the link?", + "rotateBody": "Calendars using the old link stop updating. You get a new link to add instead.", + "turnOff": "Turn off", + "turnOffTitle": "Turn off the calendar subscription?", + "turnOffBody": "The link stops working. Copies your calendar provider already holds stay until you remove the calendar there.", + "help": { + "title": "Adding it to your calendar", + "google": "Google Calendar: Other calendars, From URL, then paste the link. Google refreshes about every 12 to 24 hours.", + "apple": "Apple Calendar: on a Mac, File, New Calendar Subscription; on iPhone, Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.", + "outlook": "Outlook: Add calendar, Subscribe from web, then paste the link. Outlook refreshes every few hours." + }, + "ics": { + "calendarName": "Cycle (HealthLog)", + "periodEstimate": "Period (estimate)", + "periodLogged": "Period (logged)", + "fertileEstimate": "Fertile window (estimate)", + "likelyStart": "Most likely start {date}, {band}", + "bandOne": "±{count} day", + "bandFew": "±{count} days", + "bandOther": "±{count} days" + } + }, + "projection": { + "title": "Later cycles", + "around": "Around {date}", + "state": { + "LEARNING": "Until 3 cycles are logged, only the next period is included.", + "LATE": "No forecast while this period is later than expected; it returns once you log it.", + "OFF": "Forecasts are turned off; only past cycles are included, if chosen." + } + } }, "cycleReminders": { "periodSoonTitle": "Period likely soon", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.", "panel": "Panel", "panelPlaceholder": "e.g. Lipids", + "panelHint": "Pick a suggestion or type your own. Catalog panels are recognised in every language.", "lowerBound": "Reference low", "upperBound": "Reference high", "lowerPlaceholder": "Lower bound", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", "notLinkedYet": "Not linked to a biomarker yet", + "groups": { + "other": "Other", + "allInRange": "All in range", + "outsideCountOne": "{count} of {total} outside range", + "outsideCountFew": "{count} of {total} outside range", + "outsideCountOther": "{count} of {total} outside range", + "heading": "Categories", + "description": "Group the values by panel, such as Lipids or Liver, with a range status per group.", + "label": "Grouping", + "none": "None", + "category": "By category", + "openLabel": "Open on load", + "openExpanded": "All", + "openFlagged": "Outside range only", + "openCollapsed": "None", + "orderWithinGroups": "The order applies within each category." + }, "sourceRange": { "fromReport": "Report range {range}", "catalogDiffers": "your range {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} readings, converted from {unit} to {targetUnit}", "refused": "{unit} cannot be converted to {targetUnit}.", "aliasesAdded": "These names will lead to the kept biomarker:", + "adoptsPanel": "The kept biomarker takes over the panel {panel}.", "blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.", "noUndo": "A merge cannot be undone.", "doneToastOne": "Merged into {name}, {count} reading moved", @@ -12005,7 +12093,8 @@ "unsupportedType": "This file type is not supported.", "purged": "This document was permanently removed and cannot be restored.", "duplicateExists": "An identical document is already stored.", - "rateLimited": "Too many uploads right now. Try again in a few minutes.", + "uploadBusy": "The server was busy with other uploads. Try again now.", + "rateLimited": "You have reached the upload limit for this hour. Try again later.", "generic": "Upload failed. Please try again." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} merged biomarkers", "medicationIngredientsUpdate": "{name} changed what a supplement contains", "auditCCreate": "{name} answered the AUDIT-C questions", - "auditCDelete": "{name} removed an AUDIT-C entry" + "auditCDelete": "{name} removed an AUDIT-C entry", + "cycleFeedCreate": "{name} turned on the calendar subscription", + "cycleFeedUpdate": "{name} changed the calendar subscription", + "cycleFeedRotate": "{name} replaced the calendar subscription link", + "cycleFeedRevoke": "{name} turned off the calendar subscription" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Since your last {specialty} visit on {date}", "kind": "Since your last visit of the same kind ({kind}) on {date}", "default": "The last {days} days", - "defaultReason": "No earlier visit with the same practice, specialty or kind." + "defaultReason": "No earlier visit with the same practice, specialty or kind.", + "capped": "The last two years", + "cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Lab value, as measured", "legendLabOutside": "Outside the reference range", - "legendLabRange": "Reference range" + "legendLabRange": "Reference range", + "legendSymptom": "Symptom line: each logged day's highest intensity (0 to 10), averaged over the logged days", + "symptoms": { + "menuTitle": "Symptoms", + "full": "Up to {max} symptoms at once. Remove one to add another.", + "hidden": "hidden", + "unnamed": "Symptom", + "daysOne": "on 1 day", + "daysFew": "mean of {count} days", + "daysOther": "mean of {count} days" + } }, "lifeEvents": { "title": "Life event", diff --git a/messages/es.json b/messages/es.json index aff7c3bca..b20934c4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilidad de la frecuencia cardiaca", "typeRestingHeartRate": "Frecuencia cardíaca en reposo", "typeActiveEnergyBurned": "Energía activa", + "typeBasalEnergyBurned": "Energía en reposo", "typeFlightsClimbed": "Escaleras", "typeWalkingRunningDistance": "Distancia a pie y corriendo", "typeVo2Max": "VO₂ máx", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Sincronizado por la app de iOS o importado desde una exportación.", - "file": "Se importa en Ajustes cuando termines aquí." + "file": "Se importa en Ajustes cuando termines aquí.", + "fitbit": "Ahora se conecta a través de Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Aún no conectado.", "openSettings": "Abrir Ajustes → Integraciones", "noDate": "desconocido", + "throughGoogleHealth": "Los datos de {source} llegan ahora a través de {target}: la conexión clásica de {source} se ha cerrado. Conecta {target} con la cuenta de Google en la que tienes iniciada la sesión de {source} y tus lecturas llegarán desde ahí.", "needsCredentials": { "title": "Esta instancia aún no tiene una aplicación de {target} registrada.", "detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar." @@ -6903,7 +6907,9 @@ "summary": "{count} tipos de datos, más reciente {relative}", "quiet": "{count} en silencio", "workouts": "Entrenamientos", - "medicationDoses": "Tomas de medicación" + "medicationDoses": "Tomas de medicación", + "cycleDays": "Días del ciclo", + "cycleDaysCount": "Días del ciclo ({count})" }, "integrationFallback": { "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Mi periodo empezó este día", "endedPeriodOnDate": "Mi periodo terminó este día", "startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.", - "startRestored": "Guardado. El inicio del {dates} vuelve a estar." + "startRestored": "Guardado. El inicio del {dates} vuelve a estar.", + "feed": { + "title": "Suscripción de calendario", + "description": "Tus próximos periodos como eventos de todo el día en la app de calendario que ya usas.", + "disclosure": "Tu proveedor de calendario (Google, iCloud, Outlook) guarda estas fechas en sus servidores. Desactivarlo más tarde no borra las copias que ya tiene.", + "turnOn": "Activar suscripción", + "error": "No se pudo cambiar la suscripción de calendario. Inténtalo de nuevo.", + "linkHidden": "Hay activo un enlace que empieza por {prefix}. Solo se muestra una vez, al crearlo; renuévalo para obtener uno nuevo.", + "linkLabel": "Enlace de suscripción", + "linkOnce": "Solo se muestra ahora. Pégalo en tu app de calendario o renuévalo más tarde para obtener uno nuevo.", + "copy": "Copiar enlace", + "copied": "Enlace copiado.", + "copyFailed": "No se pudo copiar. Selecciona el enlace y cópialo a mano.", + "openInCalendar": "Abrir en la app de calendario", + "notFetched": "Aún no se ha consultado.", + "lastFetched": "Última consulta {when} por {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Calendario de Apple", + "MICROSOFT": "Outlook", + "OTHER": "una app de calendario" + }, + "horizon": "Ciclos por delante", + "history": "Ciclos pasados", + "historyNone": "Ninguno", + "titles": "Títulos de los eventos", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descriptivo" + }, + "fertile": "Ventana fértil", + "fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.", + "rotate": "Renovar enlace", + "rotateTitle": "¿Renovar el enlace?", + "rotateBody": "Los calendarios que usan el enlace antiguo dejan de actualizarse. Recibes un enlace nuevo para añadirlo en su lugar.", + "turnOff": "Desactivar", + "turnOffTitle": "¿Desactivar la suscripción de calendario?", + "turnOffBody": "El enlace deja de funcionar. Las copias que ya tiene tu proveedor de calendario se quedan hasta que elimines allí el calendario.", + "help": { + "title": "Añadirlo a tu calendario", + "google": "Google Calendar: Otros calendarios, Desde URL y pega el enlace. Google actualiza más o menos cada 12 a 24 horas.", + "apple": "Calendario de Apple: en Mac, Archivo, Nueva suscripción a calendario; en iPhone, Ajustes, Calendario, Cuentas, Añadir cuenta, Otra, Añadir calendario suscrito.", + "outlook": "Outlook: Agregar calendario, Suscribirse desde la web y pega el enlace. Outlook actualiza cada pocas horas." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Periodo (estimación)", + "periodLogged": "Periodo (registrado)", + "fertileEstimate": "Ventana fértil (estimación)", + "likelyStart": "Inicio más probable {date}, {band}", + "bandOne": "±{count} día", + "bandFew": "±{count} días", + "bandOther": "±{count} días" + } + }, + "projection": { + "title": "Ciclos siguientes", + "around": "Hacia el {date}", + "state": { + "LEARNING": "Hasta que haya 3 ciclos registrados, solo se incluye el próximo periodo.", + "LATE": "Sin previsión mientras este periodo llegue más tarde de lo esperado; vuelve en cuanto lo registres.", + "OFF": "Las previsiones están desactivadas; solo se incluyen ciclos pasados, si los elegiste." + } + } }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.", "panel": "Panel", "panelPlaceholder": "p. ej. Lípidos", + "panelHint": "Elige una sugerencia o escribe la tuya. Los paneles del catálogo se reconocen en todos los idiomas.", "lowerBound": "Referencia mínima", "upperBound": "Referencia máxima", "lowerPlaceholder": "Límite inferior", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", "notLinkedYet": "Aún no vinculado a un biomarcador", + "groups": { + "other": "Otros", + "allInRange": "Todo en rango", + "outsideCountOne": "{count} de {total} fuera de rango", + "outsideCountFew": "{count} de {total} fuera de rango", + "outsideCountOther": "{count} de {total} fuera de rango", + "heading": "Categorías", + "description": "Agrupa los valores por panel, como Lípidos o Hígado, con un estado por grupo.", + "label": "Agrupación", + "none": "Ninguna", + "category": "Por categoría", + "openLabel": "Abiertas al cargar", + "openExpanded": "Todas", + "openFlagged": "Solo fuera de rango", + "openCollapsed": "Ninguna", + "orderWithinGroups": "El orden se aplica dentro de cada categoría." + }, "sourceRange": { "fromReport": "Rango del informe {range}", "catalogDiffers": "tu rango {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}", "refused": "{unit} no se puede convertir a {targetUnit}.", "aliasesAdded": "Estos nombres llevarán al biomarcador conservado:", + "adoptsPanel": "El biomarcador conservado adopta el panel {panel}.", "blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.", "noUndo": "Una combinación no se puede deshacer.", "doneToastOne": "Combinado en {name}, {count} medición movida", @@ -12005,7 +12093,8 @@ "unsupportedType": "Este tipo de archivo no es compatible.", "purged": "Este documento se eliminó definitivamente y no se puede restaurar.", "duplicateExists": "Ya hay guardado un documento idéntico.", - "rateLimited": "Demasiadas subidas. Inténtalo de nuevo en unos minutos.", + "uploadBusy": "El servidor estaba ocupado con otras subidas. Inténtalo de nuevo ahora.", + "rateLimited": "Has alcanzado el límite de subidas de esta hora. Inténtalo de nuevo más tarde.", "generic": "La subida falló. Inténtalo de nuevo." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} combinó biomarcadores", "medicationIngredientsUpdate": "{name} cambió la composición de un suplemento", "auditCCreate": "{name} respondió el cuestionario AUDIT-C", - "auditCDelete": "{name} eliminó una entrada del AUDIT-C" + "auditCDelete": "{name} eliminó una entrada del AUDIT-C", + "cycleFeedCreate": "{name} activó la suscripción de calendario", + "cycleFeedUpdate": "{name} cambió la suscripción de calendario", + "cycleFeedRotate": "{name} renovó el enlace de la suscripción de calendario", + "cycleFeedRevoke": "{name} desactivó la suscripción de calendario" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Desde tu última visita ({specialty}) el {date}", "kind": "Desde tu última visita del mismo tipo ({kind}) el {date}", "default": "Los últimos {days} días", - "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo." + "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.", + "capped": "Los últimos dos años", + "cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Valor de laboratorio, tal como se midió", "legendLabOutside": "Fuera del intervalo de referencia", - "legendLabRange": "Intervalo de referencia" + "legendLabRange": "Intervalo de referencia", + "legendSymptom": "Línea de síntoma: la intensidad más alta de cada día registrado (0 a 10), promediada sobre los días registrados", + "symptoms": { + "menuTitle": "Síntomas", + "full": "Hasta {max} síntomas a la vez. Quita uno para añadir otro.", + "hidden": "oculto", + "unnamed": "Síntoma", + "daysOne": "en 1 día", + "daysFew": "media de {count} días", + "daysOther": "media de {count} días" + } }, "lifeEvents": { "title": "Acontecimiento vital", diff --git a/messages/fr.json b/messages/fr.json index 348f3d5a4..24dba3492 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -739,10 +739,10 @@ "emptyDescription": "Saisissez votre première mesure ou humeur. Les tuiles et graphiques apparaissent automatiquement dès qu’il y a des données.", "emptyAddMeasurement": "Saisir la première mesure", "staleHint": "Dernière valeur il y a {count} j", - "staleHintWeeksOne": "Dernière valeur il y a 1 semaine", + "staleHintWeeksOne": "Dernière valeur il y a {count} semaine", "staleHintWeeksOther": "Dernière valeur il y a {count} semaines", "staleHintWeeksFew": "Dernière valeur il y a {count} semaines", - "staleHintMonthsOne": "Dernière valeur il y a 1 mois", + "staleHintMonthsOne": "Dernière valeur il y a {count} mois", "staleHintMonthsOther": "Dernière valeur il y a {count} mois", "staleHintMonthsFew": "Dernière valeur il y a {count} mois", "recentWorkouts": { @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilité de la fréquence cardiaque", "typeRestingHeartRate": "Fréquence cardiaque au repos", "typeActiveEnergyBurned": "Énergie active", + "typeBasalEnergyBurned": "Énergie au repos", "typeFlightsClimbed": "Étages", "typeWalkingRunningDistance": "Distance à pied et en course", "typeVo2Max": "VO₂ max", @@ -2286,7 +2287,7 @@ "currentSince": "Plan actuel depuis {date}", "noTimes": "Aucune heure fixe", "manualChip": "Ajoutée manuellement", - "showErasOne": "Afficher 1 période antérieure", + "showErasOne": "Afficher {count} période antérieure", "showErasOther": "Afficher {count} périodes antérieures", "hideEras": "Masquer les périodes antérieures", "addEra": "Ajouter une période antérieure", @@ -2358,7 +2359,7 @@ "tableColActions": "Actions", "tableSortBy": "Trier par {column}", "tableComplianceDays": "{days} j", - "tableStockDoseOne": "1 dose", + "tableStockDoseOne": "{count} dose", "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} jours restants", "cardLowStockRunway": "Réserve : ≈ {days} jours", @@ -3237,7 +3238,7 @@ "settingsClustersLabel": "Données vues par le Coach", "settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.", "settingsClusterNoData": "Pas encore de données", - "sourcesMemberCountOne": "1 mesure", + "sourcesMemberCountOne": "{count} mesure", "sourcesMemberCountFew": "{count} mesures", "sourcesMemberCountOther": "{count} mesures", "lookback": { @@ -3337,7 +3338,7 @@ "indexing": "Indexation…", "indexingHint": "Un document est encore en cours d'indexation ; vous pourrez envoyer une fois prêt.", "fencedHint": "Cette conversation s'exécute sans accès à vos données de santé : le coach lit uniquement les documents joints.", - "documentsAttachedOne": "1 document joint", + "documentsAttachedOne": "{count} document joint", "documentsAttachedOther": "{count} documents joints", "railBadge": "Chat de document cloisonné", "uploadError": "Échec du téléversement. Veuillez réessayer.", @@ -3941,9 +3942,9 @@ "learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.", "learningBodyEmpty": "Dès que quelques mesures arrivent (saisies par vous ou synchronisées depuis un appareil), votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.", "readingsCount": "{count} mesures", - "readingsCountOne": "1 mesure", + "readingsCountOne": "{count} mesure", "daysCount": "{days} jours", - "daysCountOne": "1 jour", + "daysCountOne": "{count} jour", "tir": { "title": "Temps dans la cible", "caption": "Part des mesures dans chaque plage ({low}–{high} {unit} est la cible).", @@ -3995,7 +3996,7 @@ "bedtime": "Au coucher", "unspecified": "Non précisée", "readingsShort": "{count} mesures", - "readingsShortOne": "1 mesure" + "readingsShortOne": "{count} mesure" } } } @@ -4934,7 +4935,7 @@ }, "crossLink": { "title": "Enregistrements ECG", - "recordingsOne": "1 enregistrement disponible.", + "recordingsOne": "{count} enregistrement disponible.", "recordingsMany": "{count} enregistrements disponibles.", "latestResult": "Dernier résultat de l’appareil : {result}.", "cta": "Voir les enregistrements" @@ -4948,7 +4949,7 @@ "caption": "Moyenné par tranches de 10 minutes.", "hourlyNote": "Moyenne horaire affichée pour cette journée. Les jours récents affichent le détail complet par tranches de 10 minutes.", "empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.", - "coverageOne": "Basé sur 1 mesure sur {hours}h.", + "coverageOne": "Basé sur {count} mesure sur {hours}h.", "coverageMany": "Basé sur {count} mesures sur {hours}h.", "baseline": "Repos", "today": "Aujourd'hui", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Synchronisé par l'app iOS, ou importé depuis un export.", - "file": "À importer dans les réglages une fois cette étape terminée." + "file": "À importer dans les réglages une fois cette étape terminée.", + "fitbit": "Se connecte désormais via Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Pas encore connecté.", "openSettings": "Ouvrir Paramètres → Intégrations", "noDate": "inconnue", + "throughGoogleHealth": "Les données {source} passent désormais par {target} : l'ancienne connexion {source} a été arrêtée. Connectez {target} avec le compte Google auquel votre {source} est associé, et vos mesures arriveront par là.", "needsCredentials": { "title": "Cette instance n’a pas encore d’application {target} enregistrée.", "detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer." @@ -5438,7 +5442,7 @@ }, "trendHints": { "title": "Première tendance après 5 mesures", - "remainingOne": "Encore 1 mesure avant la tendance.", + "remainingOne": "Encore {count} mesure avant la tendance.", "remainingMany": "Encore {count} mesures avant la tendance." }, "settings": { @@ -6903,7 +6907,9 @@ "summary": "{count} types de données, plus récent {relative}", "quiet": "{count} silencieux", "workouts": "Séances", - "medicationDoses": "Prises de médicaments" + "medicationDoses": "Prises de médicaments", + "cycleDays": "Jours du cycle", + "cycleDaysCount": "Jours du cycle ({count})" }, "integrationFallback": { "description": "Configuré sur ce compte sans carte de réglages dédiée." @@ -8009,7 +8015,7 @@ "colRedeemedBy": "Utilisée par", "colActions": "Actions", "byCreator": "par {by}", - "expiresInDaysOne": "dans 1 jour", + "expiresInDaysOne": "dans {count} jour", "expiresInDaysOther": "dans {count} jours", "statusActive": "Active", "statusExpired": "Expirée", @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là", "startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.", - "startRestored": "Enregistré. Le début du {dates} est de retour." + "startRestored": "Enregistré. Le début du {dates} est de retour.", + "feed": { + "title": "Abonnement au calendrier", + "description": "Vos prochaines règles en événements sur la journée, dans l'application de calendrier que vous utilisez déjà.", + "disclosure": "Votre fournisseur de calendrier (Google, iCloud, Outlook) conserve ces dates sur ses serveurs. Désactiver l'abonnement plus tard ne supprime pas les copies qu'il détient déjà.", + "turnOn": "Activer l'abonnement", + "error": "L'abonnement au calendrier n'a pas pu être modifié. Réessayez.", + "linkHidden": "Un lien commençant par {prefix} est actif. Il n'est affiché qu'une fois, à sa création ; renouvelez-le pour en obtenir un nouveau.", + "linkLabel": "Lien d'abonnement", + "linkOnce": "Affiché uniquement maintenant. Collez-le dans votre application de calendrier, ou renouvelez-le plus tard pour en obtenir un nouveau.", + "copy": "Copier le lien", + "copied": "Lien copié.", + "copyFailed": "La copie n'a pas fonctionné. Sélectionnez le lien et copiez-le manuellement.", + "openInCalendar": "Ouvrir dans l'application de calendrier", + "notFetched": "Pas encore récupéré.", + "lastFetched": "Dernière récupération {when} par {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Calendrier Apple", + "MICROSOFT": "Outlook", + "OTHER": "une application de calendrier" + }, + "horizon": "Cycles à venir", + "history": "Cycles passés", + "historyNone": "Aucun", + "titles": "Titres des événements", + "titleMode": { + "DISCREET": "Discret (●)", + "DESCRIPTIVE": "Explicite" + }, + "fertile": "Fenêtre fertile", + "fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.", + "rotate": "Renouveler le lien", + "rotateTitle": "Renouveler le lien ?", + "rotateBody": "Les calendriers qui utilisent l'ancien lien ne sont plus mis à jour. Vous recevez un nouveau lien à ajouter à la place.", + "turnOff": "Désactiver", + "turnOffTitle": "Désactiver l'abonnement au calendrier ?", + "turnOffBody": "Le lien ne fonctionne plus. Les copies que détient déjà votre fournisseur de calendrier restent jusqu'à ce que vous y supprimiez le calendrier.", + "help": { + "title": "L'ajouter à votre calendrier", + "google": "Google Agenda : Autres agendas, À partir de l'URL, puis collez le lien. Google actualise environ toutes les 12 à 24 heures.", + "apple": "Calendrier Apple : sur Mac, Fichier, Nouvel abonnement à un calendrier ; sur iPhone, Réglages, Calendrier, Comptes, Ajouter un compte, Autre, Ajouter un calendrier avec abonnement.", + "outlook": "Outlook : Ajouter un calendrier, S'abonner à partir du web, puis collez le lien. Outlook actualise toutes les quelques heures." + }, + "ics": { + "calendarName": "Cycle (HealthLog)", + "periodEstimate": "Règles (estimation)", + "periodLogged": "Règles (enregistrées)", + "fertileEstimate": "Fenêtre fertile (estimation)", + "likelyStart": "Début le plus probable {date}, {band}", + "bandOne": "±{count} jour", + "bandFew": "±{count} jours", + "bandOther": "±{count} jours" + } + }, + "projection": { + "title": "Cycles suivants", + "around": "Vers le {date}", + "state": { + "LEARNING": "Tant que 3 cycles ne sont pas enregistrés, seules les prochaines règles sont incluses.", + "LATE": "Pas de prévision tant que ces règles sont plus tardives que prévu ; elle revient dès que vous les enregistrez.", + "OFF": "Les prévisions sont désactivées ; seuls les cycles passés sont inclus, si vous les avez choisis." + } + } }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.", "panel": "Panel", "panelPlaceholder": "ex. Lipides", + "panelHint": "Choisissez une suggestion ou saisissez la vôtre. Les panels du catalogue sont reconnus dans toutes les langues.", "lowerBound": "Référence basse", "upperBound": "Référence haute", "lowerPlaceholder": "Borne inférieure", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", "notLinkedYet": "Pas encore lié à un biomarqueur", + "groups": { + "other": "Autres", + "allInRange": "Tout dans la norme", + "outsideCountOne": "{count} sur {total} hors norme", + "outsideCountFew": "{count} sur {total} hors norme", + "outsideCountOther": "{count} sur {total} hors norme", + "heading": "Catégories", + "description": "Regroupe les valeurs par panel, par exemple Lipides ou Foie, avec un statut par groupe.", + "label": "Regroupement", + "none": "Aucun", + "category": "Par catégorie", + "openLabel": "Ouvertes à l’affichage", + "openExpanded": "Toutes", + "openFlagged": "Hors norme seulement", + "openCollapsed": "Aucune", + "orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie." + }, "sourceRange": { "fromReport": "Plage du compte rendu {range}", "catalogDiffers": "votre plage {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} mesures, converties de {unit} en {targetUnit}", "refused": "{unit} ne peut pas être converti en {targetUnit}.", "aliasesAdded": "Ces noms mèneront au biomarqueur conservé :", + "adoptsPanel": "Le biomarqueur conservé reprend le panel {panel}.", "blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laissez ce biomarqueur de côté ou conservez-en un autre.", "noUndo": "Une fusion ne peut pas être annulée.", "doneToastOne": "Fusionné dans {name}, {count} mesure déplacée", @@ -10947,7 +11035,7 @@ }, "insights": { "title": "Votre historique de maladies", - "countOne": "Vous avez enregistré 1 épisode sur cette période.", + "countOne": "Vous avez enregistré {count} épisode sur cette période.", "countOther": "Vous avez enregistré {count} épisodes sur cette période.", "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de vos valeurs.", "typicalGapWithDriver": "Écart de récupération typique : {days} jours. Vous vous sentez généralement mieux avant que {vital} ne se stabilise.", @@ -10960,7 +11048,7 @@ "resolved": "Résolus" }, "flares": { - "countOne": "1 poussée", + "countOne": "{count} poussée", "countOther": "{count} poussées" }, "timeline": { @@ -11092,13 +11180,13 @@ "toggleShow": "Afficher ce que le Coach a consulté", "toggleHide": "Masquer ce que le Coach a consulté", "announceDone": "Terminé : {label}", - "readingsOne": "1 mesure", + "readingsOne": "{count} mesure", "readingsFew": "{count} mesures", "readingsOther": "{count} mesures", - "rowsOne": "1 ligne", + "rowsOne": "{count} ligne", "rowsFew": "{count} lignes", "rowsOther": "{count} lignes", - "metricsOne": "1 indicateur", + "metricsOne": "{count} indicateur", "metricsFew": "{count} indicateurs", "metricsOther": "{count} indicateurs", "reason": { @@ -11202,7 +11290,7 @@ "entry": "{domain}, {window} : {detail}", "entryNoWindow": "{domain} : {detail}", "reused": "d'une réponse précédente", - "readingsOne": "1 mesure", + "readingsOne": "{count} mesure", "readingsFew": "{count} mesures", "readingsOther": "{count} mesures", "aggregation": { @@ -12005,7 +12093,8 @@ "unsupportedType": "Ce type de fichier n'est pas pris en charge.", "purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.", "duplicateExists": "Un document identique est déjà enregistré.", - "rateLimited": "Trop de téléversements. Réessayez dans quelques minutes.", + "uploadBusy": "Le serveur était occupé par d’autres téléversements. Réessayez maintenant.", + "rateLimited": "Vous avez atteint la limite de téléversements pour cette heure. Réessayez plus tard.", "generic": "Le téléversement a échoué. Veuillez réessayer." }, "toast": { @@ -12159,7 +12248,7 @@ }, "ai": { "readDone": "L’IA a lu votre document.", - "readStagedOne": "L’IA a lu votre document. 1 valeur de laboratoire attend votre vérification.", + "readStagedOne": "L’IA a lu votre document. {count} valeur de laboratoire attend votre vérification.", "readStagedFew": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.", "readStagedOther": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.", "read": "Lire avec l’IA", @@ -12191,12 +12280,12 @@ "fieldValueText": "Résultat (texte)", "fieldUnit": "Unité", "fieldDose": "Dose", - "unverifiedNoteOne": "1 valeur reste à vérifier et demeure en attente.", + "unverifiedNoteOne": "{count} valeur reste à vérifier et demeure en attente.", "unverifiedNoteFew": "{count} valeurs restent à vérifier et demeurent en attente.", "unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.", "finish": "Terminer la vérification", "confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.", - "savedToastOne": "1 valeur enregistrée", + "savedToastOne": "{count} valeur enregistrée", "savedToastFew": "{count} valeurs enregistrées", "savedToastOther": "{count} valeurs enregistrées", "nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.", @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} a fusionné des biomarqueurs", "medicationIngredientsUpdate": "{name} a modifié la composition d’un complément", "auditCCreate": "{name} a répondu au questionnaire AUDIT-C", - "auditCDelete": "{name} a supprimé une entrée AUDIT-C" + "auditCDelete": "{name} a supprimé une entrée AUDIT-C", + "cycleFeedCreate": "{name} a activé l'abonnement au calendrier", + "cycleFeedUpdate": "{name} a modifié l'abonnement au calendrier", + "cycleFeedRotate": "{name} a renouvelé le lien de l'abonnement au calendrier", + "cycleFeedRevoke": "{name} a désactivé l'abonnement au calendrier" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Depuis votre dernière visite ({specialty}) le {date}", "kind": "Depuis votre dernière visite du même type ({kind}) le {date}", "default": "Les {days} derniers jours", - "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type." + "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.", + "capped": "Les deux dernières années", + "cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans." } }, "checkups": { @@ -13510,7 +13605,7 @@ "deleteBody": "« {symptom} » et toutes ses entrées seront supprimés. Pour garder l'historique, masquez-le plutôt.", "deleteEventTitle": "Supprimer l'entrée ?", "deleteEventBody": "Cette entrée sera supprimée.", - "hiddenOne": "Masqués (1)", + "hiddenOne": "Masqués ({count})", "hiddenFew": "Masqués ({count})", "hiddenOther": "Masqués ({count})", "recent": "30 derniers jours", @@ -13577,14 +13672,14 @@ "valuesAllFew": "Les {count} valeurs", "valuesAllOther": "Les {count} valeurs", "valuesFewer": "Moins de valeurs", - "countEntriesOne": "1 entrée", + "countEntriesOne": "{count} entrée", "countEntriesFew": "{count} entrées", "countEntriesOther": "{count} entrées", - "meanOfReadingsOne": "Moyenne de 1 mesure", + "meanOfReadingsOne": "Moyenne de {count} mesure", "meanOfReadingsFew": "Moyenne de {count} mesures", "meanOfReadingsOther": "Moyenne de {count} mesures", "sinceLastVisit": { - "countOne": "1 entrée", + "countOne": "{count} entrée", "countFew": "{count} entrées", "countOther": "{count} entrées", "allOne": "Tout afficher", @@ -13773,7 +13868,7 @@ }, "noValue": "aucune valeur", "bloodPressure": "Tension artérielle", - "readingsOne": "1 mesure", + "readingsOne": "{count} mesure", "readingsFew": "moyenne de {count} mesures", "readingsOther": "moyenne de {count} mesures" }, @@ -13806,7 +13901,7 @@ "empty": "Aucune entrée de {from} à {to}", "emptySingle": "Aucune entrée en {period}", "ended": "Fin : {label}", - "afterDaysOne": "après 1 jour", + "afterDaysOne": "après {count} jour", "afterDaysFew": "après {count} jours", "afterDaysOther": "après {count} jours", "notable": { @@ -13830,7 +13925,7 @@ "apply": "Appliquer" }, "item": { - "labValuesOne": "1 valeur de laboratoire", + "labValuesOne": "{count} valeur de laboratoire", "labValuesFew": "{count} valeurs de laboratoire", "labValuesOther": "{count} valeurs de laboratoire" }, @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Valeur de laboratoire, telle que mesurée", "legendLabOutside": "Hors de l’intervalle de référence", - "legendLabRange": "Intervalle de référence" + "legendLabRange": "Intervalle de référence", + "legendSymptom": "Ligne de symptôme : intensité la plus forte de chaque jour noté (0 à 10), en moyenne sur les jours notés", + "symptoms": { + "menuTitle": "Symptômes", + "full": "Jusqu’à {max} symptômes à la fois. Retirez-en un pour en ajouter un autre.", + "hidden": "masqué", + "unnamed": "Symptôme", + "daysOne": "sur {count} jour", + "daysFew": "moyenne sur {count} jours", + "daysOther": "moyenne sur {count} jours" + } }, "lifeEvents": { "title": "Événement de vie", diff --git a/messages/it.json b/messages/it.json index ad13867cc..f0c0734a4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilità della frequenza cardiaca", "typeRestingHeartRate": "Frequenza cardiaca a riposo", "typeActiveEnergyBurned": "Energia attiva", + "typeBasalEnergyBurned": "Energia a riposo", "typeFlightsClimbed": "Piani saliti", "typeWalkingRunningDistance": "Distanza a piedi e di corsa", "typeVo2Max": "VO₂ max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Sincronizzato dall'app iOS, oppure importato da un'esportazione.", - "file": "Si importa nelle Impostazioni quando hai finito qui." + "file": "Si importa nelle Impostazioni quando hai finito qui.", + "fitbit": "Ora si collega tramite Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Non ancora collegato.", "openSettings": "Apri Impostazioni → Integrazioni", "noDate": "sconosciuto", + "throughGoogleHealth": "I dati {source} ora arrivano tramite {target}: la connessione classica a {source} è stata chiusa. Collega {target} con l'account Google a cui è associato il tuo {source} e le tue misurazioni arriveranno da lì.", "needsCredentials": { "title": "Questa istanza non ha ancora un’app {target} registrata.", "detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire." @@ -6903,7 +6907,9 @@ "summary": "{count} tipi di dati, più recente {relative}", "quiet": "{count} in silenzio", "workouts": "Allenamenti", - "medicationDoses": "Assunzioni di farmaci" + "medicationDoses": "Assunzioni di farmaci", + "cycleDays": "Giorni del ciclo", + "cycleDaysCount": "Giorni del ciclo ({count})" }, "integrationFallback": { "description": "Configurato su questo account ma senza una scheda di impostazioni propria." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno", "startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.", - "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente." + "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente.", + "feed": { + "title": "Abbonamento al calendario", + "description": "Le tue prossime mestruazioni come eventi di un'intera giornata nell'app di calendario che usi già.", + "disclosure": "Il tuo fornitore di calendario (Google, iCloud, Outlook) conserva queste date sui suoi server. Disattivare l'abbonamento in seguito non elimina le copie che ha già.", + "turnOn": "Attiva abbonamento", + "error": "Non è stato possibile modificare l'abbonamento al calendario. Riprova.", + "linkHidden": "È attivo un link che inizia con {prefix}. Viene mostrato una sola volta, quando viene creato; rinnovalo per averne uno nuovo.", + "linkLabel": "Link di abbonamento", + "linkOnce": "Visibile solo adesso. Incollalo nella tua app di calendario, oppure rinnovalo più tardi per averne uno nuovo.", + "copy": "Copia link", + "copied": "Link copiato.", + "copyFailed": "La copia non è riuscita. Seleziona il link e copialo a mano.", + "openInCalendar": "Apri nell'app di calendario", + "notFetched": "Non ancora scaricato.", + "lastFetched": "Ultimo scaricamento {when} da {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Calendario di Apple", + "MICROSOFT": "Outlook", + "OTHER": "un'app di calendario" + }, + "horizon": "Cicli in avanti", + "history": "Cicli passati", + "historyNone": "Nessuno", + "titles": "Titoli degli eventi", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descrittivo" + }, + "fertile": "Finestra fertile", + "fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.", + "rotate": "Rinnova link", + "rotateTitle": "Rinnovare il link?", + "rotateBody": "I calendari che usano il vecchio link smettono di aggiornarsi. Ricevi un nuovo link da aggiungere al suo posto.", + "turnOff": "Disattiva", + "turnOffTitle": "Disattivare l'abbonamento al calendario?", + "turnOffBody": "Il link smette di funzionare. Le copie che il tuo fornitore di calendario ha già restano finché non rimuovi lì il calendario.", + "help": { + "title": "Aggiungerlo al calendario", + "google": "Google Calendar: Altri calendari, Da URL, poi incolla il link. Google aggiorna circa ogni 12-24 ore.", + "apple": "Calendario di Apple: sul Mac, File, Nuovo abbonamento a calendario; su iPhone, Impostazioni, Calendario, Account, Aggiungi account, Altro, Aggiungi calendario sottoscritto.", + "outlook": "Outlook: Aggiungi calendario, Sottoscrivi dal Web, poi incolla il link. Outlook aggiorna ogni poche ore." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Mestruazioni (stima)", + "periodLogged": "Mestruazioni (registrate)", + "fertileEstimate": "Finestra fertile (stima)", + "likelyStart": "Inizio più probabile {date}, {band}", + "bandOne": "±{count} giorno", + "bandFew": "±{count} giorni", + "bandOther": "±{count} giorni" + } + }, + "projection": { + "title": "Cicli successivi", + "around": "Intorno al {date}", + "state": { + "LEARNING": "Finché non sono registrati 3 cicli, è incluso solo il prossimo ciclo mestruale.", + "LATE": "Nessuna previsione finché queste mestruazioni sono più in ritardo del previsto; torna appena le registri.", + "OFF": "Le previsioni sono disattivate; sono inclusi solo i cicli passati, se li hai scelti." + } + } }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.", "panel": "Panel", "panelPlaceholder": "es. Lipidi", + "panelHint": "Scegli un suggerimento o scrivine uno tuo. I panel del catalogo vengono riconosciuti in ogni lingua.", "lowerBound": "Riferimento minimo", "upperBound": "Riferimento massimo", "lowerPlaceholder": "Limite inferiore", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", "notLinkedYet": "Non ancora collegato a un biomarcatore", + "groups": { + "other": "Altri", + "allInRange": "Tutti nella norma", + "outsideCountOne": "{count} su {total} fuori norma", + "outsideCountFew": "{count} su {total} fuori norma", + "outsideCountOther": "{count} su {total} fuori norma", + "heading": "Categorie", + "description": "Raggruppa i valori per panel, ad esempio Lipidi o Fegato, con uno stato per gruppo.", + "label": "Raggruppamento", + "none": "Nessuno", + "category": "Per categoria", + "openLabel": "Aperte al caricamento", + "openExpanded": "Tutte", + "openFlagged": "Solo fuori norma", + "openCollapsed": "Nessuna", + "orderWithinGroups": "L’ordine vale all’interno di ogni categoria." + }, "sourceRange": { "fromReport": "Intervallo del referto {range}", "catalogDiffers": "il tuo intervallo {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}", "refused": "{unit} non può essere convertito in {targetUnit}.", "aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:", + "adoptsPanel": "Il biomarcatore mantenuto assume il panel {panel}.", "blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.", "noUndo": "Un'unione non può essere annullata.", "doneToastOne": "Unito in {name}, {count} misurazione spostata", @@ -12005,7 +12093,8 @@ "unsupportedType": "Questo tipo di file non è supportato.", "purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.", "duplicateExists": "Un documento identico è già archiviato.", - "rateLimited": "Troppi caricamenti. Riprova tra qualche minuto.", + "uploadBusy": "Il server era occupato con altri caricamenti. Riprova adesso.", + "rateLimited": "Hai raggiunto il limite di caricamenti per quest’ora. Riprova più tardi.", "generic": "Caricamento non riuscito. Riprova." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} ha unito dei biomarcatori", "medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore", "auditCCreate": "{name} ha compilato il questionario AUDIT-C", - "auditCDelete": "{name} ha eliminato una voce AUDIT-C" + "auditCDelete": "{name} ha eliminato una voce AUDIT-C", + "cycleFeedCreate": "{name} ha attivato l'abbonamento al calendario", + "cycleFeedUpdate": "{name} ha modificato l'abbonamento al calendario", + "cycleFeedRotate": "{name} ha rinnovato il link dell'abbonamento al calendario", + "cycleFeedRevoke": "{name} ha disattivato l'abbonamento al calendario" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Dalla tua ultima visita ({specialty}) del {date}", "kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}", "default": "Gli ultimi {days} giorni", - "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo." + "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.", + "capped": "Gli ultimi due anni", + "cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Valore di laboratorio, così come misurato", "legendLabOutside": "Fuori dall’intervallo di riferimento", - "legendLabRange": "Intervallo di riferimento" + "legendLabRange": "Intervallo di riferimento", + "legendSymptom": "Linea del sintomo: l’intensità più alta di ogni giorno registrato (da 0 a 10), in media sui giorni registrati", + "symptoms": { + "menuTitle": "Sintomi", + "full": "Fino a {max} sintomi alla volta. Rimuovine uno per aggiungerne un altro.", + "hidden": "nascosto", + "unnamed": "Sintomo", + "daysOne": "in 1 giorno", + "daysFew": "media di {count} giorni", + "daysOther": "media di {count} giorni" + } }, "lifeEvents": { "title": "Evento di vita", diff --git a/messages/ko.json b/messages/ko.json index 98499067d..f8dfaefe1 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "심박변이도", "typeRestingHeartRate": "안정 시 심박수", "typeActiveEnergyBurned": "활동 에너지", + "typeBasalEnergyBurned": "휴식 에너지", "typeFlightsClimbed": "오른 층수", "typeWalkingRunningDistance": "걷기 + 달리기 거리", "typeVo2Max": "VO₂ max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.", - "file": "여기서 마친 뒤 설정에서 가져옵니다." + "file": "여기서 마친 뒤 설정에서 가져옵니다.", + "fitbit": "이제 Google Health를 통해 연결됩니다." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "아직 연결되지 않았습니다.", "openSettings": "설정 → 연동 열기", "noDate": "알 수 없음", + "throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.", "needsCredentials": { "title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.", "detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다." @@ -6903,7 +6907,9 @@ "summary": "데이터 유형 {count}개, 최신 {relative}", "quiet": "{count}개 잠잠", "workouts": "운동", - "medicationDoses": "약 복용" + "medicationDoses": "약 복용", + "cycleDays": "주기 기록일", + "cycleDaysCount": "주기 기록일 ({count})" }, "integrationFallback": { "description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "이 날 생리를 시작했어요", "endedPeriodOnDate": "이 날 생리가 끝났어요", "startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.", - "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요." + "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요.", + "feed": { + "title": "캘린더 구독", + "description": "다음 생리 예정일을 지금 쓰는 캘린더 앱에 종일 일정으로 보여줘요.", + "disclosure": "캘린더 제공업체(Google, iCloud, Outlook)는 이 날짜를 자사 서버에 저장해요. 나중에 구독을 꺼도 이미 가져간 사본은 지워지지 않아요.", + "turnOn": "구독 켜기", + "error": "캘린더 구독을 변경하지 못했어요. 다시 시도해 주세요.", + "linkHidden": "{prefix}(으)로 시작하는 링크가 활성 상태예요. 링크는 만들 때 한 번만 보여요. 새 링크가 필요하면 다시 발급하세요.", + "linkLabel": "구독 링크", + "linkOnce": "지금만 보여요. 캘린더 앱에 붙여 넣거나, 나중에 다시 발급해 새 링크를 받으세요.", + "copy": "링크 복사", + "copied": "링크를 복사했어요.", + "copyFailed": "복사하지 못했어요. 링크를 선택해 직접 복사하세요.", + "openInCalendar": "캘린더 앱에서 열기", + "notFetched": "아직 가져가지 않았어요.", + "lastFetched": "{fetcher}에서 마지막으로 가져감: {when}.", + "fetcher": { + "GOOGLE": "Google 캘린더", + "APPLE": "Apple 캘린더", + "MICROSOFT": "Outlook", + "OTHER": "캘린더 앱" + }, + "horizon": "앞으로의 주기", + "history": "지난 주기", + "historyNone": "없음", + "titles": "일정 제목", + "titleMode": { + "DISCREET": "드러나지 않게 (●)", + "DESCRIPTIVE": "내용 표시" + }, + "fertile": "가임기", + "fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.", + "rotate": "링크 다시 발급", + "rotateTitle": "링크를 다시 발급할까요?", + "rotateBody": "이전 링크를 쓰는 캘린더는 더 이상 업데이트되지 않아요. 대신 추가할 새 링크를 받게 돼요.", + "turnOff": "끄기", + "turnOffTitle": "캘린더 구독을 끌까요?", + "turnOffBody": "링크가 더 이상 작동하지 않아요. 캘린더 제공업체가 이미 가진 사본은 거기서 캘린더를 삭제할 때까지 남아 있어요.", + "help": { + "title": "캘린더에 추가하는 방법", + "google": "Google 캘린더: 다른 캘린더, URL로 추가에서 링크를 붙여 넣으세요. Google은 대략 12~24시간마다 새로 고쳐요.", + "apple": "Apple 캘린더: Mac에서는 파일, 새로운 캘린더 구독. iPhone에서는 설정, 캘린더, 계정, 계정 추가, 기타, 구독 캘린더 추가.", + "outlook": "Outlook: 캘린더 추가, 웹에서 구독에서 링크를 붙여 넣으세요. Outlook은 몇 시간마다 새로 고쳐요." + }, + "ics": { + "calendarName": "주기 (HealthLog)", + "periodEstimate": "생리 (예상)", + "periodLogged": "생리 (기록)", + "fertileEstimate": "가임기 (예상)", + "likelyStart": "가장 가능성 높은 시작일 {date}, {band}", + "bandOne": "±{count}일", + "bandFew": "±{count}일", + "bandOther": "±{count}일" + } + }, + "projection": { + "title": "이후 주기", + "around": "{date}쯤", + "state": { + "LEARNING": "주기가 3번 기록될 때까지는 다음 생리만 포함돼요.", + "LATE": "이번 생리가 예상보다 늦어지는 동안에는 예측이 없어요. 기록하면 다시 나타나요.", + "OFF": "예측이 꺼져 있어요. 선택한 경우 지난 주기만 포함돼요." + } + } }, "cycleReminders": { "periodSoonTitle": "생리가 곧 시작될 것 같아요", @@ -10316,6 +10385,7 @@ "unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.", "panel": "패널", "panelPlaceholder": "예: 지질", + "panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.", "lowerBound": "참고 하한", "upperBound": "참고 상한", "lowerPlaceholder": "하한값", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "측정값 더 보기", "documentsLink": "검사 결과지 문서", "notLinkedYet": "아직 바이오마커에 연결되지 않았어요", + "groups": { + "other": "기타", + "allInRange": "모두 범위 내", + "outsideCountOne": "{total}개 중 {count}개 범위 밖", + "outsideCountFew": "{total}개 중 {count}개 범위 밖", + "outsideCountOther": "{total}개 중 {count}개 범위 밖", + "heading": "카테고리", + "description": "지질, 간처럼 패널별로 값을 묶고 그룹마다 상태를 보여줘요.", + "label": "묶기", + "none": "없음", + "category": "카테고리별", + "openLabel": "열 때 펼치기", + "openExpanded": "모두", + "openFlagged": "범위 밖만", + "openCollapsed": "없음", + "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요." + }, "sourceRange": { "fromReport": "결과지 범위 {range}", "catalogDiffers": "내 범위 {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", "refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.", "aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:", + "adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.", "blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.", "noUndo": "합친 뒤에는 되돌릴 수 없어요.", "doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", @@ -12005,7 +12093,8 @@ "unsupportedType": "지원하지 않는 파일 형식이에요.", "purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.", "duplicateExists": "같은 문서가 이미 저장돼 있어요.", - "rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.", + "uploadBusy": "서버가 다른 업로드를 처리하느라 바빴어요. 지금 다시 시도해 주세요.", + "rateLimited": "이번 시간의 업로드 한도에 도달했어요. 나중에 다시 시도해 주세요.", "generic": "업로드하지 못했어요. 다시 시도해 주세요." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요", "medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요", "auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요", - "auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요" + "auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요", + "cycleFeedCreate": "{name} 님이 캘린더 구독을 켰어요", + "cycleFeedUpdate": "{name} 님이 캘린더 구독을 변경했어요", + "cycleFeedRotate": "{name} 님이 캘린더 구독 링크를 다시 발급했어요", + "cycleFeedRevoke": "{name} 님이 캘린더 구독을 껐어요" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "{date} 마지막 {specialty} 방문 이후", "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", "default": "최근 {days}일", - "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.", + "capped": "최근 2년", + "cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "측정된 그대로의 검사 수치", "legendLabOutside": "참고 범위 밖", - "legendLabRange": "참고 범위" + "legendLabRange": "참고 범위", + "legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값", + "symptoms": { + "menuTitle": "증상", + "full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.", + "hidden": "숨김", + "unnamed": "증상", + "daysOne": "1일", + "daysFew": "{count}일 평균", + "daysOther": "{count}일 평균" + } }, "lifeEvents": { "title": "삶의 사건", diff --git a/messages/nl.json b/messages/nl.json index 96d427f61..462f15c05 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Hartslagvariabiliteit", "typeRestingHeartRate": "Rusthartslag", "typeActiveEnergyBurned": "Actieve energie", + "typeBasalEnergyBurned": "Rustenergie", "typeFlightsClimbed": "Beklommen verdiepingen", "typeWalkingRunningDistance": "Afstand wandelen + hardlopen", "typeVo2Max": "VO₂max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Gesynchroniseerd door de iOS-app, of geïmporteerd uit een export.", - "file": "Te importeren onder Instellingen zodra je hier klaar bent." + "file": "Te importeren onder Instellingen zodra je hier klaar bent.", + "fitbit": "Wordt nu gekoppeld via Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Nog niet gekoppeld.", "openSettings": "Instellingen → Koppelingen openen", "noDate": "onbekend", + "throughGoogleHealth": "{source}-gegevens komen nu binnen via {target}: de klassieke {source}-koppeling is stopgezet. Koppel {target} met het Google-account waarmee je {source} is aangemeld, dan komen je metingen daarvandaan.", "needsCredentials": { "title": "Deze instantie heeft nog geen {target}-app geregistreerd.", "detail": "Voer je eigen inloggegevens in onder Instellingen → Koppelingen, daarna kan de koppeling starten." @@ -6903,7 +6907,9 @@ "summary": "{count} gegevenstypen, nieuwste {relative}", "quiet": "{count} stil", "workouts": "Trainingen", - "medicationDoses": "Medicatiedoses" + "medicationDoses": "Medicatiedoses", + "cycleDays": "Cyclusdagen", + "cycleDaysCount": "Cyclusdagen ({count})" }, "integrationFallback": { "description": "Ingesteld op dit account zonder eigen instellingenkaart." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Mijn menstruatie begon op deze dag", "endedPeriodOnDate": "Mijn menstruatie eindigde op deze dag", "startAbsorbed": "Begin van menstruatie opgeslagen. Je begin op {absorbed} is samengevoegd met {date}.", - "startRestored": "Opgeslagen. Het begin op {dates} is terug." + "startRestored": "Opgeslagen. Het begin op {dates} is terug.", + "feed": { + "title": "Agenda-abonnement", + "description": "Je volgende menstruaties als afspraken voor de hele dag in de agenda-app die je al gebruikt.", + "disclosure": "Je agendaprovider (Google, iCloud, Outlook) bewaart deze datums op zijn servers. Als je dit later uitzet, blijven kopieën die hij al heeft daar staan.", + "turnOn": "Abonnement aanzetten", + "error": "Het agenda-abonnement kon niet worden gewijzigd. Probeer het opnieuw.", + "linkHidden": "Er is een link actief die begint met {prefix}. Die wordt maar één keer getoond, bij het aanmaken; vernieuw hem voor een nieuwe.", + "linkLabel": "Abonnementslink", + "linkOnce": "Alleen nu zichtbaar. Plak hem in je agenda-app, of vernieuw hem later voor een nieuwe.", + "copy": "Link kopiëren", + "copied": "Link gekopieerd.", + "copyFailed": "Kopiëren is niet gelukt. Selecteer de link en kopieer hem zelf.", + "openInCalendar": "Openen in agenda-app", + "notFetched": "Nog niet opgehaald.", + "lastFetched": "Laatst opgehaald {when} door {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Apple Agenda", + "MICROSOFT": "Outlook", + "OTHER": "een agenda-app" + }, + "horizon": "Cycli vooruit", + "history": "Eerdere cycli", + "historyNone": "Geen", + "titles": "Titels van afspraken", + "titleMode": { + "DISCREET": "Discreet (●)", + "DESCRIPTIVE": "Beschrijvend" + }, + "fertile": "Vruchtbare periode", + "fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.", + "rotate": "Link vernieuwen", + "rotateTitle": "Link vernieuwen?", + "rotateBody": "Agenda's met de oude link worden niet meer bijgewerkt. Je krijgt een nieuwe link om in plaats daarvan toe te voegen.", + "turnOff": "Uitzetten", + "turnOffTitle": "Agenda-abonnement uitzetten?", + "turnOffBody": "De link werkt niet meer. Kopieën die je agendaprovider al heeft, blijven staan tot je de agenda daar verwijdert.", + "help": { + "title": "Toevoegen aan je agenda", + "google": "Google Agenda: Andere agenda's, Via URL, en plak de link. Google ververst ongeveer elke 12 tot 24 uur.", + "apple": "Apple Agenda: op de Mac Archief, Nieuw agenda-abonnement; op de iPhone Instellingen, Agenda, Accounts, Voeg account toe, Andere, Voeg agenda-abonnement toe.", + "outlook": "Outlook: Agenda toevoegen, Abonneren vanaf internet, en plak de link. Outlook ververst om de paar uur." + }, + "ics": { + "calendarName": "Cyclus (HealthLog)", + "periodEstimate": "Menstruatie (schatting)", + "periodLogged": "Menstruatie (geregistreerd)", + "fertileEstimate": "Vruchtbare periode (schatting)", + "likelyStart": "Meest waarschijnlijke begin {date}, {band}", + "bandOne": "±{count} dag", + "bandFew": "±{count} dagen", + "bandOther": "±{count} dagen" + } + }, + "projection": { + "title": "Latere cycli", + "around": "Rond {date}", + "state": { + "LEARNING": "Tot er 3 cycli zijn geregistreerd, is alleen de volgende menstruatie opgenomen.", + "LATE": "Geen voorspelling zolang deze menstruatie later is dan verwacht; die komt terug zodra je haar registreert.", + "OFF": "Voorspellingen staan uit; alleen eerdere cycli zijn opgenomen, als je die hebt gekozen." + } + } }, "cycleReminders": { "periodSoonTitle": "Menstruatie waarschijnlijk binnenkort", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Zodra deze marker metingen heeft, blijft de eenheid zoals die is. Anders zouden de opgeslagen waarden zonder omrekening in een nieuwe eenheid worden getoond.", "panel": "Groep", "panelPlaceholder": "bijv. Lipiden", + "panelHint": "Kies een suggestie of typ je eigen groep. Groepen uit de catalogus worden in elke taal herkend.", "lowerBound": "Referentie ondergrens", "upperBound": "Referentie bovengrens", "lowerPlaceholder": "Ondergrens", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Meer metingen laden", "documentsLink": "Documenten met labrapporten", "notLinkedYet": "Nog niet gekoppeld aan een biomarker", + "groups": { + "other": "Overig", + "allInRange": "Alles binnen bereik", + "outsideCountOne": "{count} van {total} buiten bereik", + "outsideCountFew": "{count} van {total} buiten bereik", + "outsideCountOther": "{count} van {total} buiten bereik", + "heading": "Categorieën", + "description": "Groepeert de waarden per groep, zoals Lipiden of Lever, met een status per groep.", + "label": "Groeperen", + "none": "Niet", + "category": "Per categorie", + "openLabel": "Open bij laden", + "openExpanded": "Alle", + "openFlagged": "Alleen buiten bereik", + "openCollapsed": "Geen", + "orderWithinGroups": "De volgorde geldt binnen elke categorie." + }, "sourceRange": { "fromReport": "Bereik volgens rapport: {range}", "catalogDiffers": "jouw bereik: {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} metingen, omgerekend van {unit} naar {targetUnit}", "refused": "{unit} kan niet worden omgerekend naar {targetUnit}.", "aliasesAdded": "Deze namen verwijzen voortaan naar de behouden biomarker:", + "adoptsPanel": "De behouden biomarker neemt de groep {panel} over.", "blocked": "Er kan niets worden samengevoegd zolang een eenheid niet kan worden omgerekend. Laat die biomarker weg of behoud een andere.", "noUndo": "Samenvoegen kan niet ongedaan worden gemaakt.", "doneToastOne": "Samengevoegd in {name}, {count} meting verplaatst", @@ -12005,7 +12093,8 @@ "unsupportedType": "Dit bestandstype wordt niet ondersteund.", "purged": "Dit document is definitief verwijderd en kan niet worden hersteld.", "duplicateExists": "Er is al een identiek document opgeslagen.", - "rateLimited": "Te veel uploads op dit moment. Probeer het over een paar minuten opnieuw.", + "uploadBusy": "De server was bezig met andere uploads. Probeer het nu opnieuw.", + "rateLimited": "Je hebt de uploadlimiet voor dit uur bereikt. Probeer het later opnieuw.", "generic": "Uploaden is mislukt. Probeer het opnieuw." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} heeft biomarkers samengevoegd", "medicationIngredientsUpdate": "{name} heeft de samenstelling van een supplement gewijzigd", "auditCCreate": "{name} heeft de AUDIT-C-vragen beantwoord", - "auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd" + "auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd", + "cycleFeedCreate": "{name} heeft het agenda-abonnement aangezet", + "cycleFeedUpdate": "{name} heeft het agenda-abonnement gewijzigd", + "cycleFeedRotate": "{name} heeft de link van het agenda-abonnement vernieuwd", + "cycleFeedRevoke": "{name} heeft het agenda-abonnement uitgezet" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Sinds je laatste bezoek ({specialty}) op {date}", "kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}", "default": "De afgelopen {days} dagen", - "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort." + "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.", + "capped": "De afgelopen twee jaar", + "cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Labwaarde, zoals gemeten", "legendLabOutside": "Buiten het referentiebereik", - "legendLabRange": "Referentiebereik" + "legendLabRange": "Referentiebereik", + "legendSymptom": "Symptoomlijn: de hoogste intensiteit van elke vastgelegde dag (0 tot 10), gemiddeld over de vastgelegde dagen", + "symptoms": { + "menuTitle": "Symptomen", + "full": "Maximaal {max} symptomen tegelijk. Verwijder er een om een ander toe te voegen.", + "hidden": "verborgen", + "unnamed": "Symptoom", + "daysOne": "op 1 dag", + "daysFew": "gemiddelde van {count} dagen", + "daysOther": "gemiddelde van {count} dagen" + } }, "lifeEvents": { "title": "Levensgebeurtenis", diff --git a/messages/pl.json b/messages/pl.json index d5fb8cd97..f83b58363 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Zmienność rytmu serca", "typeRestingHeartRate": "Tętno spoczynkowe", "typeActiveEnergyBurned": "Aktywna energia", + "typeBasalEnergyBurned": "Energia spoczynkowa", "typeFlightsClimbed": "Pokonane piętra", "typeWalkingRunningDistance": "Dystans marszu i biegu", "typeVo2Max": "VO₂ max", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Synchronizowane przez aplikację iOS lub importowane z eksportu.", - "file": "Import w Ustawieniach, gdy skończysz tutaj." + "file": "Import w Ustawieniach, gdy skończysz tutaj.", + "fitbit": "Teraz łączy się przez Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Jeszcze nie połączono.", "openSettings": "Otwórz Ustawienia → Integracje", "noDate": "nieznany", + "throughGoogleHealth": "Dane z {source} trafiają teraz przez {target}: klasyczne połączenie z {source} zostało wyłączone. Połącz {target} z kontem Google, na którym zalogowany jest Twój {source}, a pomiary zaczną napływać stamtąd.", "needsCredentials": { "title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.", "detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć." @@ -6903,7 +6907,9 @@ "summary": "{count} typów danych, najnowsze {relative}", "quiet": "{count} bez danych", "workouts": "Treningi", - "medicationDoses": "Dawki leków" + "medicationDoses": "Dawki leków", + "cycleDays": "Dni cyklu", + "cycleDaysCount": "Dni cyklu ({count})" }, "integrationFallback": { "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień." @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", "endedPeriodOnDate": "Miesiączka skończyła się tego dnia", "startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.", - "startRestored": "Zapisano. Początek z {dates} jest z powrotem." + "startRestored": "Zapisano. Początek z {dates} jest z powrotem.", + "feed": { + "title": "Subskrypcja kalendarza", + "description": "Twoje kolejne miesiączki jako wydarzenia całodniowe w aplikacji kalendarza, której już używasz.", + "disclosure": "Twój dostawca kalendarza (Google, iCloud, Outlook) przechowuje te daty na swoich serwerach. Późniejsze wyłączenie nie usuwa kopii, które już ma.", + "turnOn": "Włącz subskrypcję", + "error": "Nie udało się zmienić subskrypcji kalendarza. Spróbuj ponownie.", + "linkHidden": "Aktywny jest link zaczynający się od {prefix}. Jest pokazywany tylko raz, przy utworzeniu; odnów go, aby dostać nowy.", + "linkLabel": "Link subskrypcji", + "linkOnce": "Widoczny tylko teraz. Wklej go do aplikacji kalendarza albo odnów go później, aby dostać nowy.", + "copy": "Kopiuj link", + "copied": "Link skopiowany.", + "copyFailed": "Kopiowanie nie zadziałało. Zaznacz link i skopiuj go ręcznie.", + "openInCalendar": "Otwórz w aplikacji kalendarza", + "notFetched": "Jeszcze nie pobrano.", + "lastFetched": "Ostatnio pobrane {when} przez: {fetcher}.", + "fetcher": { + "GOOGLE": "Kalendarz Google", + "APPLE": "Kalendarz Apple", + "MICROSOFT": "Outlook", + "OTHER": "aplikacja kalendarza" + }, + "horizon": "Cykle do przodu", + "history": "Poprzednie cykle", + "historyNone": "Brak", + "titles": "Tytuły wydarzeń", + "titleMode": { + "DISCREET": "Dyskretne (●)", + "DESCRIPTIVE": "Opisowe" + }, + "fertile": "Okno płodne", + "fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.", + "rotate": "Odnów link", + "rotateTitle": "Odnowić link?", + "rotateBody": "Kalendarze ze starym linkiem przestaną się aktualizować. Dostaniesz nowy link, który dodasz zamiast niego.", + "turnOff": "Wyłącz", + "turnOffTitle": "Wyłączyć subskrypcję kalendarza?", + "turnOffBody": "Link przestanie działać. Kopie, które ma już Twój dostawca kalendarza, zostaną, dopóki nie usuniesz tam kalendarza.", + "help": { + "title": "Jak dodać go do kalendarza", + "google": "Kalendarz Google: Inne kalendarze, Z adresu URL, potem wklej link. Google odświeża mniej więcej co 12 do 24 godzin.", + "apple": "Kalendarz Apple: na Macu Plik, Nowa subskrypcja kalendarza; na iPhonie Ustawienia, Kalendarz, Konta, Dodaj konto, Inne, Dodaj subskrybowany kalendarz.", + "outlook": "Outlook: Dodaj kalendarz, Subskrybuj z sieci Web, potem wklej link. Outlook odświeża co kilka godzin." + }, + "ics": { + "calendarName": "Cykl (HealthLog)", + "periodEstimate": "Miesiączka (szacunek)", + "periodLogged": "Miesiączka (zapisana)", + "fertileEstimate": "Okno płodne (szacunek)", + "likelyStart": "Najbardziej prawdopodobny początek {date}, {band}", + "bandOne": "±{count} dzień", + "bandFew": "±{count} dni", + "bandOther": "±{count} dni" + } + }, + "projection": { + "title": "Kolejne cykle", + "around": "Około {date}", + "state": { + "LEARNING": "Dopóki nie zapiszesz 3 cykli, uwzględniana jest tylko najbliższa miesiączka.", + "LATE": "Brak prognozy, dopóki ta miesiączka spóźnia się bardziej niż oczekiwano; wróci, gdy ją zapiszesz.", + "OFF": "Prognozy są wyłączone; uwzględniane są tylko poprzednie cykle, jeśli je wybrano." + } + } }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.", "panel": "Panel", "panelPlaceholder": "np. Lipidy", + "panelHint": "Wybierz podpowiedź lub wpisz własny. Panele z katalogu są rozpoznawane w każdym języku.", "lowerBound": "Dolna granica", "upperBound": "Górna granica", "lowerPlaceholder": "Dolna granica", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", + "groups": { + "other": "Inne", + "allInRange": "Wszystkie w normie", + "outsideCountOne": "{count} z {total} poza normą", + "outsideCountFew": "{count} z {total} poza normą", + "outsideCountOther": "{count} z {total} poza normą", + "heading": "Kategorie", + "description": "Grupuje wartości według panelu, np. Lipidy lub Wątroba, ze statusem dla każdej grupy.", + "label": "Grupowanie", + "none": "Brak", + "category": "Według kategorii", + "openLabel": "Rozwinięte po otwarciu", + "openExpanded": "Wszystkie", + "openFlagged": "Tylko poza normą", + "openCollapsed": "Żadne", + "orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii." + }, "sourceRange": { "fromReport": "Zakres z wyniku {range}", "catalogDiffers": "twój zakres {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}", "refused": "Nie można przeliczyć {unit} na {targetUnit}.", "aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:", + "adoptsPanel": "Zachowany biomarker przejmuje panel {panel}.", "blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.", "noUndo": "Scalenia nie można cofnąć.", "doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar", @@ -12005,7 +12093,8 @@ "unsupportedType": "Ten typ pliku nie jest obsługiwany.", "purged": "Ten dokument został trwale usunięty i nie można go przywrócić.", "duplicateExists": "Identyczny dokument jest już zapisany.", - "rateLimited": "Zbyt wiele przesyłań. Spróbuj ponownie za kilka minut.", + "uploadBusy": "Serwer był zajęty innymi przesyłaniami. Spróbuj ponownie teraz.", + "rateLimited": "Osiągnięto limit przesyłań na tę godzinę. Spróbuj ponownie później.", "generic": "Przesyłanie nie powiodło się. Spróbuj ponownie." }, "toast": { @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} scalił biomarkery", "medicationIngredientsUpdate": "{name} zmienił skład suplementu", "auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C", - "auditCDelete": "{name} usunął wpis AUDIT-C" + "auditCDelete": "{name} usunął wpis AUDIT-C", + "cycleFeedCreate": "{name} włączył subskrypcję kalendarza", + "cycleFeedUpdate": "{name} zmienił subskrypcję kalendarza", + "cycleFeedRotate": "{name} odnowił link subskrypcji kalendarza", + "cycleFeedRevoke": "{name} wyłączył subskrypcję kalendarza" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Od ostatniej wizyty ({specialty}) {date}", "kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}", "default": "Ostatnie {days} dni", - "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju." + "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.", + "capped": "Ostatnie dwa lata", + "cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata." } }, "checkups": { @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Wynik badania, tak jak zmierzono", "legendLabOutside": "Poza zakresem referencyjnym", - "legendLabRange": "Zakres referencyjny" + "legendLabRange": "Zakres referencyjny", + "legendSymptom": "Linia objawu: najwyższe natężenie każdego zapisanego dnia (od 0 do 10), uśrednione po zapisanych dniach", + "symptoms": { + "menuTitle": "Objawy", + "full": "Naraz można pokazać do {max} objawów. Usuń jeden, aby dodać inny.", + "hidden": "ukryty", + "unnamed": "Objaw", + "daysOne": "w 1 dniu", + "daysFew": "średnia z {count} dni", + "daysOther": "średnia z {count} dni" + } }, "lifeEvents": { "title": "Wydarzenie życiowe", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 7436334e1..97989a554 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -739,10 +739,10 @@ "emptyDescription": "Registre sua primeira medição ou entrada de humor. Blocos e gráficos aparecem automaticamente conforme os dados chegam.", "emptyAddMeasurement": "Adicionar sua primeira medição", "staleHint": "Última leitura há {count} d", - "staleHintWeeksOne": "Última leitura há 1 semana", + "staleHintWeeksOne": "Última leitura há {count} semana", "staleHintWeeksOther": "Última leitura há {count} semanas", "staleHintWeeksFew": "Última leitura há {count} semanas", - "staleHintMonthsOne": "Última leitura há 1 mês", + "staleHintMonthsOne": "Última leitura há {count} mês", "staleHintMonthsOther": "Última leitura há {count} meses", "staleHintMonthsFew": "Última leitura há {count} meses", "recentWorkouts": { @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilidade da frequência cardíaca", "typeRestingHeartRate": "Frequência cardíaca em repouso", "typeActiveEnergyBurned": "Energia ativa", + "typeBasalEnergyBurned": "Energia em repouso", "typeFlightsClimbed": "Andares subidos", "typeWalkingRunningDistance": "Distância caminhada + corrida", "typeVo2Max": "VO2 máx", @@ -2286,7 +2287,7 @@ "currentSince": "Plano atual desde {date}", "noTimes": "Sem horários fixos", "manualChip": "Adicionado manualmente", - "showErasOne": "Mostrar 1 era anterior", + "showErasOne": "Mostrar {count} era anterior", "showErasOther": "Mostrar {count} eras anteriores", "hideEras": "Ocultar eras anteriores", "addEra": "Adicionar era anterior", @@ -2358,7 +2359,7 @@ "tableColActions": "Ações", "tableSortBy": "Ordenar por {column}", "tableComplianceDays": "{days} d", - "tableStockDoseOne": "1 dose", + "tableStockDoseOne": "{count} dose", "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} dias restantes", "cardLowStockRunway": "Estoque: ≈ {days} dias", @@ -3237,7 +3238,7 @@ "settingsClustersLabel": "Dados que o Coach vê", "settingsClustersHint": "Escolha quais grupos dos seus dados o Coach pode ler. Mais dados significam respostas mais ricas e um prompt maior.", "settingsClusterNoData": "Ainda sem dados", - "sourcesMemberCountOne": "1 métrica", + "sourcesMemberCountOne": "{count} métrica", "sourcesMemberCountFew": "{count} métricas", "sourcesMemberCountOther": "{count} métricas", "lookback": { @@ -3337,7 +3338,7 @@ "indexing": "Indexando…", "indexingHint": "Um documento ainda está sendo indexado. Você pode enviar quando estiver pronto.", "fencedHint": "Esta conversa roda sem acesso aos seus dados de saúde. O coach lê apenas os documentos anexados.", - "documentsAttachedOne": "1 documento anexado", + "documentsAttachedOne": "{count} documento anexado", "documentsAttachedOther": "{count} documentos anexados", "railBadge": "Conversa de documento isolada", "uploadError": "Falha no envio. Tente novamente.", @@ -3941,9 +3942,9 @@ "learningBody": "Até agora são {count} em {days}. Um pouco mais de histórico e um quadro mais completo entra em foco. Continue registrando, ou deixe um dispositivo conectado preencher.", "learningBodyEmpty": "Assim que algumas leituras chegarem (registradas por você ou sincronizadas de um dispositivo), seu tempo na faixa, média e variabilidade tomarão forma aqui.", "readingsCount": "{count} leituras", - "readingsCountOne": "1 leitura", + "readingsCountOne": "{count} leitura", "daysCount": "{days} dias", - "daysCountOne": "1 dia", + "daysCountOne": "{count} dia", "tir": { "title": "Tempo na faixa", "caption": "Parcela das leituras em cada faixa ({low}–{high} {unit} é a meta).", @@ -3995,7 +3996,7 @@ "bedtime": "Hora de dormir", "unspecified": "Sem marcação", "readingsShort": "{count} leituras", - "readingsShortOne": "1 leitura" + "readingsShortOne": "{count} leitura" } } } @@ -4934,7 +4935,7 @@ }, "crossLink": { "title": "Registros de ECG", - "recordingsOne": "1 registro em arquivo.", + "recordingsOne": "{count} registro em arquivo.", "recordingsMany": "{count} registros em arquivo.", "latestResult": "Resultado mais recente do dispositivo: {result}.", "cta": "Ver registros" @@ -4948,7 +4949,7 @@ "caption": "Média em intervalos de 10 minutos.", "hourlyNote": "Mostrando a média horária deste dia; dias recentes mostram o detalhe completo de 10 minutos.", "empty": "Não há leituras de frequência cardíaca suficientes para este dia mostrar sua forma ainda.", - "coverageOne": "Com base em 1 leitura ao longo de {hours}h.", + "coverageOne": "Com base em {count} leitura ao longo de {hours}h.", "coverageMany": "Com base em {count} leituras ao longo de {hours}h.", "baseline": "Em repouso", "today": "Hoje", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Sincronizado pelo aplicativo iOS ou importado de uma exportação.", - "file": "Importado nas Configurações quando terminar aqui." + "file": "Importado nas Configurações quando terminar aqui.", + "fitbit": "Agora é conectado pelo Google Health." } }, "visit": { @@ -5338,6 +5341,7 @@ "waiting": "Ainda não conectado.", "openSettings": "Abrir Configurações → Integrações", "noDate": "desconhecido", + "throughGoogleHealth": "Os dados do {source} agora chegam pelo {target}: a conexão clássica com o {source} foi desativada. Conecte o {target} com a conta Google em que o seu {source} está conectado, e suas leituras chegam por lá.", "needsCredentials": { "title": "Esta instância ainda não tem um aplicativo {target} registrado.", "detail": "Insira suas próprias credenciais em Configurações → Integrações, então a conexão pode começar." @@ -5438,7 +5442,7 @@ }, "trendHints": { "title": "Primeira tendência após 5 leituras", - "remainingOne": "Falta 1 leitura.", + "remainingOne": "Falta {count} leitura.", "remainingMany": "Faltam {count} leituras." }, "settings": { @@ -6903,7 +6907,9 @@ "summary": "{count} tipos de dados, mais recente {relative}", "quiet": "{count} sem atividade", "workouts": "Treinos", - "medicationDoses": "Doses de medicamentos" + "medicationDoses": "Doses de medicamentos", + "cycleDays": "Dias do ciclo", + "cycleDaysCount": "Dias do ciclo ({count})" }, "integrationFallback": { "description": "Configurado nesta conta sem um cartão de configurações próprio." @@ -8009,7 +8015,7 @@ "colRedeemedBy": "Resgatado por", "colActions": "Ações", "byCreator": "por {by}", - "expiresInDaysOne": "em 1 dia", + "expiresInDaysOne": "em {count} dia", "expiresInDaysOther": "em {count} dias", "statusActive": "Ativo", "statusExpired": "Expirado", @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "Minha menstruação começou neste dia", "endedPeriodOnDate": "Minha menstruação terminou neste dia", "startAbsorbed": "Início da menstruação salvo. Seu início em {absorbed} foi mesclado em {date}.", - "startRestored": "Salvo. O início em {dates} está de volta." + "startRestored": "Salvo. O início em {dates} está de volta.", + "feed": { + "title": "Assinatura de calendário", + "description": "Suas próximas menstruações como eventos de dia inteiro no app de calendário que você já usa.", + "disclosure": "Seu provedor de calendário (Google, iCloud, Outlook) guarda essas datas nos servidores dele. Desativar depois não apaga as cópias que ele já tem.", + "turnOn": "Ativar assinatura", + "error": "Não foi possível alterar a assinatura de calendário. Tente de novo.", + "linkHidden": "Um link que começa com {prefix} está ativo. Ele só aparece uma vez, ao ser criado; renove-o para ter um novo.", + "linkLabel": "Link da assinatura", + "linkOnce": "Só aparece agora. Cole no seu app de calendário ou renove depois para ter um novo.", + "copy": "Copiar link", + "copied": "Link copiado.", + "copyFailed": "Não deu para copiar. Selecione o link e copie manualmente.", + "openInCalendar": "Abrir no app de calendário", + "notFetched": "Ainda não buscado.", + "lastFetched": "Última busca {when} por {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Calendário da Apple", + "MICROSOFT": "Outlook", + "OTHER": "um app de calendário" + }, + "horizon": "Ciclos à frente", + "history": "Ciclos anteriores", + "historyNone": "Nenhum", + "titles": "Títulos dos eventos", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descritivo" + }, + "fertile": "Janela fértil", + "fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.", + "rotate": "Renovar link", + "rotateTitle": "Renovar o link?", + "rotateBody": "Calendários com o link antigo deixam de ser atualizados. Você recebe um novo link para adicionar no lugar.", + "turnOff": "Desativar", + "turnOffTitle": "Desativar a assinatura de calendário?", + "turnOffBody": "O link para de funcionar. As cópias que seu provedor de calendário já tem ficam lá até você remover o calendário.", + "help": { + "title": "Como adicionar ao seu calendário", + "google": "Google Agenda: Outras agendas, Do URL, depois cole o link. O Google atualiza mais ou menos a cada 12 a 24 horas.", + "apple": "Calendário da Apple: no Mac, Arquivo, Nova Assinatura de Calendário; no iPhone, Ajustes, Calendário, Contas, Adicionar Conta, Outra, Adicionar Calendário Assinado.", + "outlook": "Outlook: Adicionar calendário, Assinar da Web, depois cole o link. O Outlook atualiza a cada poucas horas." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Menstruação (estimativa)", + "periodLogged": "Menstruação (registrada)", + "fertileEstimate": "Janela fértil (estimativa)", + "likelyStart": "Início mais provável {date}, {band}", + "bandOne": "±{count} dia", + "bandFew": "±{count} dias", + "bandOther": "±{count} dias" + } + }, + "projection": { + "title": "Ciclos seguintes", + "around": "Por volta de {date}", + "state": { + "LEARNING": "Até haver 3 ciclos registrados, só a próxima menstruação é incluída.", + "LATE": "Sem previsão enquanto esta menstruação estiver mais atrasada que o esperado; ela volta assim que você a registrar.", + "OFF": "As previsões estão desativadas; só ciclos anteriores são incluídos, se escolhidos." + } + } }, "cycleReminders": { "periodSoonTitle": "Menstruação provavelmente em breve", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Depois que este marcador tiver leituras, sua unidade permanece como está. Caso contrário, os valores armazenados seriam exibidos em uma nova unidade sem serem convertidos.", "panel": "Painel", "panelPlaceholder": "ex.: Lipídios", + "panelHint": "Escolha uma sugestão ou digite o seu. Os painéis do catálogo são reconhecidos em todos os idiomas.", "lowerBound": "Referência baixa", "upperBound": "Referência alta", "lowerPlaceholder": "Limite inferior", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Carregar mais leituras", "documentsLink": "Documentos de laudos laboratoriais", "notLinkedYet": "Ainda não vinculado a um biomarcador", + "groups": { + "other": "Outros", + "allInRange": "Tudo na faixa", + "outsideCountOne": "{count} de {total} fora da faixa", + "outsideCountFew": "{count} de {total} fora da faixa", + "outsideCountOther": "{count} de {total} fora da faixa", + "heading": "Categorias", + "description": "Agrupa os valores por painel, como Lipídios ou Fígado, com um status por grupo.", + "label": "Agrupamento", + "none": "Nenhum", + "category": "Por categoria", + "openLabel": "Abertas ao carregar", + "openExpanded": "Todas", + "openFlagged": "Só fora da faixa", + "openCollapsed": "Nenhuma", + "orderWithinGroups": "A ordem vale dentro de cada categoria." + }, "sourceRange": { "fromReport": "Faixa do laudo {range}", "catalogDiffers": "sua faixa {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} leituras, convertidas de {unit} para {targetUnit}", "refused": "{unit} não pode ser convertida para {targetUnit}.", "aliasesAdded": "Estes nomes levarão ao biomarcador mantido:", + "adoptsPanel": "O biomarcador mantido assume o painel {panel}.", "blocked": "Nada pode ser mesclado enquanto uma unidade não puder ser convertida. Deixe esse biomarcador fora ou mantenha outro.", "noUndo": "Uma mesclagem não pode ser desfeita.", "doneToastOne": "Mesclado em {name}, {count} leitura movida", @@ -10947,7 +11035,7 @@ }, "insights": { "title": "Seu histórico de doenças", - "countOne": "Você registrou 1 episódio neste período.", + "countOne": "Você registrou {count} episódio neste período.", "countOther": "Você registrou {count} episódios neste período.", "typicalGap": "Lacuna típica de recuperação: {days} dias entre se sentir melhor e seus números se estabilizarem.", "typicalGapWithDriver": "Lacuna típica de recuperação: {days} dias; você tende a se sentir melhor antes de seu {vital} se estabilizar.", @@ -10960,7 +11048,7 @@ "resolved": "Resolvidos" }, "flares": { - "countOne": "1 surto", + "countOne": "{count} surto", "countOther": "{count} surtos" }, "timeline": { @@ -11092,13 +11180,13 @@ "toggleShow": "Mostrar o que o Coach examinou", "toggleHide": "Ocultar o que o Coach examinou", "announceDone": "Concluído: {label}", - "readingsOne": "1 leitura", + "readingsOne": "{count} leitura", "readingsFew": "{count} leituras", "readingsOther": "{count} leituras", - "rowsOne": "1 linha", + "rowsOne": "{count} linha", "rowsFew": "{count} linhas", "rowsOther": "{count} linhas", - "metricsOne": "1 métrica", + "metricsOne": "{count} métrica", "metricsFew": "{count} métricas", "metricsOther": "{count} métricas", "reason": { @@ -11202,7 +11290,7 @@ "entry": "{domain}, {window}: {detail}", "entryNoWindow": "{domain}: {detail}", "reused": "de uma resposta anterior", - "readingsOne": "1 leitura", + "readingsOne": "{count} leitura", "readingsFew": "{count} leituras", "readingsOther": "{count} leituras", "aggregation": { @@ -12005,7 +12093,8 @@ "unsupportedType": "Este tipo de arquivo não é suportado.", "purged": "Este documento foi removido permanentemente e não pode ser restaurado.", "duplicateExists": "Um documento idêntico já está armazenado.", - "rateLimited": "Envios demais agora. Tente novamente em alguns minutos.", + "uploadBusy": "O servidor estava ocupado com outros envios. Tente novamente agora.", + "rateLimited": "Você atingiu o limite de envios desta hora. Tente novamente mais tarde.", "generic": "Falha no envio. Tente novamente." }, "toast": { @@ -12159,7 +12248,7 @@ }, "ai": { "readDone": "A IA leu seu documento.", - "readStagedOne": "A IA leu seu documento. 1 valor laboratorial está pronto para revisão.", + "readStagedOne": "A IA leu seu documento. {count} valor laboratorial está pronto para revisão.", "readStagedFew": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.", "readStagedOther": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.", "read": "Ler com IA", @@ -12191,12 +12280,12 @@ "fieldValueText": "Resultado (texto)", "fieldUnit": "Unidade", "fieldDose": "Dose", - "unverifiedNoteOne": "1 valor ainda precisa de verificação e permanece pendente.", + "unverifiedNoteOne": "{count} valor ainda precisa de verificação e permanece pendente.", "unverifiedNoteFew": "{count} valores ainda precisam de verificação e permanecem pendentes.", "unverifiedNoteOther": "{count} valores ainda precisam de verificação e permanecem pendentes.", "finish": "Concluir revisão", "confirmFailed": "A revisão não pôde ser salva. Tente novamente.", - "savedToastOne": "1 valor salvo", + "savedToastOne": "{count} valor salvo", "savedToastFew": "{count} valores salvos", "savedToastOther": "{count} valores salvos", "nothingSavedToast": "Revisão salva. Nenhum valor foi adicionado.", @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} mesclou biomarcadores", "medicationIngredientsUpdate": "{name} alterou o que um suplemento contém", "auditCCreate": "{name} respondeu às perguntas AUDIT-C", - "auditCDelete": "{name} removeu uma entrada AUDIT-C" + "auditCDelete": "{name} removeu uma entrada AUDIT-C", + "cycleFeedCreate": "{name} ativou a assinatura de calendário", + "cycleFeedUpdate": "{name} alterou a assinatura de calendário", + "cycleFeedRotate": "{name} renovou o link da assinatura de calendário", + "cycleFeedRevoke": "{name} desativou a assinatura de calendário" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "Desde a sua última consulta de {specialty}, em {date}", "kind": "Desde a sua última consulta do mesmo tipo ({kind}), em {date}", "default": "Os últimos {days} dias", - "defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo." + "defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.", + "capped": "Os últimos dois anos", + "cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos." } }, "checkups": { @@ -13510,7 +13605,7 @@ "deleteBody": "“{symptom}” e todas as suas entradas serão excluídos. Para manter o histórico, oculte-o em vez disso.", "deleteEventTitle": "Excluir entrada?", "deleteEventBody": "Esta entrada será excluída.", - "hiddenOne": "Ocultos (1)", + "hiddenOne": "Ocultos ({count})", "hiddenFew": "Ocultos ({count})", "hiddenOther": "Ocultos ({count})", "recent": "Últimos 30 dias", @@ -13577,14 +13672,14 @@ "valuesAllFew": "Todos os {count} valores", "valuesAllOther": "Todos os {count} valores", "valuesFewer": "Menos valores", - "countEntriesOne": "1 entrada", + "countEntriesOne": "{count} entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", - "meanOfReadingsOne": "Média de 1 leitura", + "meanOfReadingsOne": "Média de {count} leitura", "meanOfReadingsFew": "Média de {count} leituras", "meanOfReadingsOther": "Média de {count} leituras", "sinceLastVisit": { - "countOne": "1 entrada", + "countOne": "{count} entrada", "countFew": "{count} entradas", "countOther": "{count} entradas", "allOne": "Mostrar tudo", @@ -13773,7 +13868,7 @@ }, "noValue": "sem valor", "bloodPressure": "Pressão arterial", - "readingsOne": "1 leitura", + "readingsOne": "{count} leitura", "readingsFew": "média de {count} leituras", "readingsOther": "média de {count} leituras" }, @@ -13806,7 +13901,7 @@ "empty": "Nenhuma entrada de {from} a {to}", "emptySingle": "Nenhuma entrada em {period}", "ended": "{label} terminou", - "afterDaysOne": "após 1 dia", + "afterDaysOne": "após {count} dia", "afterDaysFew": "após {count} dias", "afterDaysOther": "após {count} dias", "notable": { @@ -13830,7 +13925,7 @@ "apply": "Aplicar" }, "item": { - "labValuesOne": "1 valor laboratorial", + "labValuesOne": "{count} valor laboratorial", "labValuesFew": "{count} valores laboratoriais", "labValuesOther": "{count} valores laboratoriais" }, @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Valor de exame, como foi medido", "legendLabOutside": "Fora da faixa de referência", - "legendLabRange": "Faixa de referência" + "legendLabRange": "Faixa de referência", + "legendSymptom": "Linha do sintoma: a maior intensidade de cada dia registrado (de 0 a 10), em média sobre os dias registrados", + "symptoms": { + "menuTitle": "Sintomas", + "full": "Até {max} sintomas ao mesmo tempo. Remova um para adicionar outro.", + "hidden": "oculto", + "unnamed": "Sintoma", + "daysOne": "em {count} dia", + "daysFew": "média de {count} dias", + "daysOther": "média de {count} dias" + } }, "lifeEvents": { "title": "Evento de vida", diff --git a/messages/ru.json b/messages/ru.json index 7ef35a608..b83a16b5a 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -739,10 +739,10 @@ "emptyDescription": "Запишите первое измерение или настроение. Плитки и графики появятся автоматически по мере поступления данных.", "emptyAddMeasurement": "Добавить первое измерение", "staleHint": "Последнее измерение {count} дн. назад", - "staleHintWeeksOne": "Последнее измерение 1 неделю назад", + "staleHintWeeksOne": "Последнее измерение {count} неделю назад", "staleHintWeeksOther": "Последнее измерение {count} недель назад", "staleHintWeeksFew": "Последнее измерение {count} недели назад", - "staleHintMonthsOne": "Последнее измерение 1 месяц назад", + "staleHintMonthsOne": "Последнее измерение {count} месяц назад", "staleHintMonthsOther": "Последнее измерение {count} месяцев назад", "staleHintMonthsFew": "Последнее измерение {count} месяца назад", "recentWorkouts": { @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Вариабельность сердечного ритма", "typeRestingHeartRate": "ЧСС в покое", "typeActiveEnergyBurned": "Активная энергия", + "typeBasalEnergyBurned": "Энергия в покое", "typeFlightsClimbed": "Этажи", "typeWalkingRunningDistance": "Расстояние ходьбы + бега", "typeVo2Max": "VO₂ max", @@ -2286,7 +2287,7 @@ "currentSince": "Текущий план с {date}", "noTimes": "Без фиксированного времени", "manualChip": "Добавлено вручную", - "showErasOne": "Показать 1 прошлый период", + "showErasOne": "Показать {count} прошлый период", "showErasOther": "Показать прошлые периоды ({count})", "hideEras": "Скрыть прошлые периоды", "addEra": "Добавить прошлый период", @@ -2358,7 +2359,7 @@ "tableColActions": "Действия", "tableSortBy": "Сортировать по {column}", "tableComplianceDays": "{days} дн.", - "tableStockDoseOne": "1 доза", + "tableStockDoseOne": "{count} доза", "tableStockDoses": "{count} доз", "tableStockRunway": "≈ {days} дн. осталось", "cardLowStockRunway": "Запас: ≈ {days} дн.", @@ -3237,7 +3238,7 @@ "settingsClustersLabel": "Данные, которые видит коуч", "settingsClustersHint": "Выберите, какие кластеры ваших данных может читать коуч. Больше данных — богаче ответы и больше запрос.", "settingsClusterNoData": "Данных пока нет", - "sourcesMemberCountOne": "1 показатель", + "sourcesMemberCountOne": "{count} показатель", "sourcesMemberCountFew": "{count} показателя", "sourcesMemberCountOther": "{count} показателей", "lookback": { @@ -3337,7 +3338,7 @@ "indexing": "Индексация…", "indexingHint": "Документ ещё индексируется. Отправить можно, когда он будет готов.", "fencedHint": "Этот разговор идёт без доступа к вашим данным о здоровье. Коуч читает только прикреплённые документы.", - "documentsAttachedOne": "Прикреплён 1 документ", + "documentsAttachedOne": "Прикреплён {count} документ", "documentsAttachedOther": "Прикреплено документов: {count}", "railBadge": "Изолированный чат по документам", "uploadError": "Загрузка не удалась. Попробуйте ещё раз.", @@ -3941,9 +3942,9 @@ "learningBody": "Пока это {count} за {days}. Ещё немного истории, и более полная картина прояснится. Продолжайте записывать или дайте подключённому устройству заполнить её.", "learningBodyEmpty": "Как только поступит несколько измерений (записанных вами или синхронизированных с устройства), здесь сформируются ваше время в диапазоне, среднее и вариабельность.", "readingsCount": "Измерений: {count}", - "readingsCountOne": "1 измерение", + "readingsCountOne": "{count} измерение", "daysCount": "Дней: {days}", - "daysCountOne": "1 день", + "daysCountOne": "{count} день", "tir": { "title": "Время в диапазоне", "caption": "Доля измерений в каждой полосе ({low}–{high} {unit} — цель).", @@ -3995,7 +3996,7 @@ "bedtime": "Перед сном", "unspecified": "Без метки", "readingsShort": "Измерений: {count}", - "readingsShortOne": "1 измерение" + "readingsShortOne": "{count} измерение" } } } @@ -4934,7 +4935,7 @@ }, "crossLink": { "title": "Записи ЭКГ", - "recordingsOne": "1 запись в архиве.", + "recordingsOne": "{count} запись в архиве.", "recordingsMany": "Записей в архиве: {count}.", "latestResult": "Последний результат устройства: {result}.", "cta": "Посмотреть записи" @@ -4948,7 +4949,7 @@ "caption": "Усреднено шагами по 10 минут.", "hourlyNote": "Показано среднее по часам за этот день; для недавних дней доступна полная детализация по 10 минут.", "empty": "Пока недостаточно измерений ЧСС за этот день, чтобы показать его форму.", - "coverageOne": "На основе 1 измерения за {hours} ч.", + "coverageOne": "На основе {count} измерения за {hours} ч.", "coverageMany": "На основе {count} измерений за {hours} ч.", "baseline": "В покое", "today": "Сегодня", @@ -5277,6 +5278,7 @@ "oura": "Oura", "whoop": "WHOOP", "polar": "Polar", + "garmin": "Garmin", "fitbit": "Fitbit", "strava": "Strava", "nightscout": "Nightscout", @@ -5284,7 +5286,8 @@ }, "hints": { "apple-health": "Синхронизируется приложением iOS или импортируется из экспорта.", - "file": "Импортируется в настройках, когда вы здесь закончите." + "file": "Импортируется в настройках, когда вы здесь закончите.", + "fitbit": "Теперь подключается через Google Health." } }, "visit": { @@ -5332,12 +5335,13 @@ "connect-source": { "title": "Подключить {target}", "body": "Одно подключение, выбранное по вашим ответам. Затем измерения будут поступать сами.", - "howTo": "Подключение к {target} выполняется на странице интеграций. Вернитесь сюда afterwards; этот экран заметит, когда всё будет готово.", + "howTo": "Подключение к {target} выполняется на странице интеграций. Потом вернитесь сюда: этот экран заметит, когда всё будет готово.", "cta": "Подключить {target}", "checking": "Проверка подключения.", "waiting": "Пока не подключено.", "openSettings": "Открыть Настройки → Интеграции", "noDate": "неизвестно", + "throughGoogleHealth": "Данные {source} теперь поступают через {target}: классическое подключение {source} отключено. Подключите {target} с тем аккаунтом Google, в который выполнен вход на вашем {source}, и показания будут приходить оттуда.", "needsCredentials": { "title": "В этом экземпляре пока нет приложения {target}.", "detail": "Введите свои учётные данные в разделе Настройки → Интеграции, и подключение сможет начаться." @@ -5438,7 +5442,7 @@ }, "trendHints": { "title": "Первая динамика после 5 измерений", - "remainingOne": "Осталось 1 измерение.", + "remainingOne": "Осталось {count} измерение.", "remainingMany": "Осталось измерений: {count}." }, "settings": { @@ -6903,7 +6907,9 @@ "summary": "Типов данных: {count}, новейшие {relative}", "quiet": "Без новых данных: {count}", "workouts": "Тренировки", - "medicationDoses": "Дозы лекарств" + "medicationDoses": "Дозы лекарств", + "cycleDays": "Дни цикла", + "cycleDaysCount": "Дни цикла ({count})" }, "integrationFallback": { "description": "Настроено в этом аккаунте без собственной карточки настроек." @@ -8009,7 +8015,7 @@ "colRedeemedBy": "Активировано", "colActions": "Действия", "byCreator": "от {by}", - "expiresInDaysOne": "через 1 день", + "expiresInDaysOne": "через {count} день", "expiresInDaysOther": "через {count} дней", "statusActive": "Активно", "statusExpired": "Истекло", @@ -10020,7 +10026,70 @@ "startedPeriodOnDate": "У меня началась менструация в этот день", "endedPeriodOnDate": "У меня закончилась менструация в этот день", "startAbsorbed": "Начало менструации сохранено. Ваше начало {absorbed} объединено с {date}.", - "startRestored": "Сохранено. Начало {dates} восстановлено." + "startRestored": "Сохранено. Начало {dates} восстановлено.", + "feed": { + "title": "Подписка на календарь", + "description": "Ваши следующие месячные в виде событий на весь день в календаре, которым вы уже пользуетесь.", + "disclosure": "Ваш поставщик календаря (Google, iCloud, Outlook) хранит эти даты на своих серверах. Если позже отключить подписку, уже полученные им копии не удалятся.", + "turnOn": "Включить подписку", + "error": "Не удалось изменить подписку на календарь. Попробуйте ещё раз.", + "linkHidden": "Активна ссылка, начинающаяся с {prefix}. Она показывается только один раз, при создании; обновите её, чтобы получить новую.", + "linkLabel": "Ссылка подписки", + "linkOnce": "Видна только сейчас. Вставьте её в приложение календаря или обновите позже, чтобы получить новую.", + "copy": "Копировать ссылку", + "copied": "Ссылка скопирована.", + "copyFailed": "Скопировать не удалось. Выделите ссылку и скопируйте её вручную.", + "openInCalendar": "Открыть в календаре", + "notFetched": "Ещё не загружалась.", + "lastFetched": "Последняя загрузка {when}, клиент: {fetcher}.", + "fetcher": { + "GOOGLE": "Google Календарь", + "APPLE": "Календарь Apple", + "MICROSOFT": "Outlook", + "OTHER": "приложение календаря" + }, + "horizon": "Циклов вперёд", + "history": "Прошлые циклы", + "historyNone": "Нет", + "titles": "Названия событий", + "titleMode": { + "DISCREET": "Незаметно (●)", + "DESCRIPTIVE": "Подробно" + }, + "fertile": "Фертильное окно", + "fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.", + "rotate": "Обновить ссылку", + "rotateTitle": "Обновить ссылку?", + "rotateBody": "Календари со старой ссылкой перестанут обновляться. Вы получите новую ссылку, которую нужно добавить вместо неё.", + "turnOff": "Отключить", + "turnOffTitle": "Отключить подписку на календарь?", + "turnOffBody": "Ссылка перестанет работать. Копии, которые уже есть у поставщика календаря, останутся, пока вы не удалите там календарь.", + "help": { + "title": "Как добавить в календарь", + "google": "Google Календарь: Другие календари, Добавить по URL, затем вставьте ссылку. Google обновляет примерно раз в 12-24 часа.", + "apple": "Календарь Apple: на Mac Файл, Новая подписка на календарь; на iPhone Настройки, Календарь, Учетные записи, Добавить учетную запись, Другое, Добавить подписной календарь.", + "outlook": "Outlook: Добавить календарь, Подписаться из Интернета, затем вставьте ссылку. Outlook обновляет каждые несколько часов." + }, + "ics": { + "calendarName": "Цикл (HealthLog)", + "periodEstimate": "Месячные (оценка)", + "periodLogged": "Месячные (записаны)", + "fertileEstimate": "Фертильное окно (оценка)", + "likelyStart": "Наиболее вероятное начало {date}, {band}", + "bandOne": "±{count} день", + "bandFew": "±{count} дня", + "bandOther": "±{count} дней" + } + }, + "projection": { + "title": "Следующие циклы", + "around": "Около {date}", + "state": { + "LEARNING": "Пока не записаны 3 цикла, включаются только ближайшие месячные.", + "LATE": "Прогноза нет, пока эти месячные запаздывают сильнее ожидаемого; он вернётся, как только вы их запишете.", + "OFF": "Прогнозы отключены; включаются только прошлые циклы, если они выбраны." + } + } }, "cycleReminders": { "periodSoonTitle": "Скоро вероятная менструация", @@ -10316,6 +10385,7 @@ "unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.", "panel": "Панель", "panelPlaceholder": "например Липиды", + "panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.", "lowerBound": "Нижняя граница", "upperBound": "Верхняя граница", "lowerPlaceholder": "Нижняя граница", @@ -10499,6 +10569,23 @@ "loadMoreReadings": "Загрузить больше измерений", "documentsLink": "Документы анализов", "notLinkedYet": "Пока не связано с показателем", + "groups": { + "other": "Другое", + "allInRange": "Все в диапазоне", + "outsideCountOne": "{count} из {total} вне диапазона", + "outsideCountFew": "{count} из {total} вне диапазона", + "outsideCountOther": "{count} из {total} вне диапазона", + "heading": "Категории", + "description": "Группирует значения по панелям, например Липиды или Печень, со статусом для каждой группы.", + "label": "Группировка", + "none": "Нет", + "category": "По категориям", + "openLabel": "Раскрыто при открытии", + "openExpanded": "Все", + "openFlagged": "Только вне диапазона", + "openCollapsed": "Ни одной", + "orderWithinGroups": "Порядок действует внутри каждой категории." + }, "sourceRange": { "fromReport": "Диапазон из отчёта {range}", "catalogDiffers": "ваш диапазон {range}", @@ -10545,6 +10632,7 @@ "convertedOther": "{count} измерений, пересчитаны из {unit} в {targetUnit}", "refused": "{unit} нельзя пересчитать в {targetUnit}.", "aliasesAdded": "Эти имена будут вести к оставленному показателю:", + "adoptsPanel": "Оставленный показатель получает панель {panel}.", "blocked": "Объединить нельзя, пока единицу не удаётся пересчитать. Исключите этот показатель или оставьте другой.", "noUndo": "Объединение нельзя отменить.", "doneToastOne": "Объединено в {name}, перенесено измерений: {count}", @@ -10947,7 +11035,7 @@ }, "insights": { "title": "Ваша история заболеваний", - "countOne": "За этот период вы записали 1 эпизод.", + "countOne": "За этот период вы записали {count} эпизод.", "countOther": "За этот период вы записали эпизодов: {count}.", "typicalGap": "Типичный разрыв восстановления: {days} дней между улучшением самочувствия и нормализацией показателей.", "typicalGapWithDriver": "Типичный разрыв восстановления: {days} дней; вы обычно чувствуете себя лучше раньше, чем нормализуется {vital}.", @@ -10960,7 +11048,7 @@ "resolved": "Завершённые" }, "flares": { - "countOne": "1 обострение", + "countOne": "{count} обострение", "countOther": "{count} обострений" }, "timeline": { @@ -11092,13 +11180,13 @@ "toggleShow": "Показать, что просмотрел Коуч", "toggleHide": "Скрыть, что просмотрел Коуч", "announceDone": "Готово: {label}", - "readingsOne": "1 измерение", + "readingsOne": "{count} измерение", "readingsFew": "Измерений: {count}", "readingsOther": "Измерений: {count}", - "rowsOne": "1 строка", + "rowsOne": "{count} строка", "rowsFew": "{count} строки", "rowsOther": "{count} строк", - "metricsOne": "1 показатель", + "metricsOne": "{count} показатель", "metricsFew": "{count} показателя", "metricsOther": "{count} показателей", "reason": { @@ -11202,7 +11290,7 @@ "entry": "{domain}, {window}: {detail}", "entryNoWindow": "{domain}: {detail}", "reused": "из более раннего ответа", - "readingsOne": "1 измерение", + "readingsOne": "{count} измерение", "readingsFew": "Измерений: {count}", "readingsOther": "Измерений: {count}", "aggregation": { @@ -12005,7 +12093,8 @@ "unsupportedType": "Этот тип файла не поддерживается.", "purged": "Этот документ удалён навсегда и не может быть восстановлен.", "duplicateExists": "Идентичный документ уже сохранён.", - "rateLimited": "Слишком много загрузок сейчас. Попробуйте через несколько минут.", + "uploadBusy": "Сервер был занят другими загрузками. Попробуйте ещё раз сейчас.", + "rateLimited": "Достигнут лимит загрузок на этот час. Попробуйте позже.", "generic": "Загрузка не удалась. Попробуйте ещё раз." }, "toast": { @@ -12135,7 +12224,7 @@ "errorGeneric": "Не удалось прочитать документ. Попробуйте ещё раз.", "errorOcr": "Не удалось прочитать текст с этого изображения.", "errorRateLimited": "Достигнут часовой лимит чтения с ИИ. Попробуйте позже.", - "errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через минуту.", + "errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуту.", "errorRateLimitedWaitFew": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуты.", "errorRateLimitedWaitOther": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минут.", "errorBudget": "Ваш бюджет ИИ на сегодня исчерпан.", @@ -12159,7 +12248,7 @@ }, "ai": { "readDone": "ИИ прочитал ваш документ.", - "readStagedOne": "ИИ прочитал ваш документ. 1 результат анализа готов к проверке.", + "readStagedOne": "ИИ прочитал ваш документ. {count} результат анализа готов к проверке.", "readStagedFew": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.", "readStagedOther": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.", "read": "Прочитать с ИИ", @@ -12191,12 +12280,12 @@ "fieldValueText": "Результат (текст)", "fieldUnit": "Единица", "fieldDose": "Доза", - "unverifiedNoteOne": "1 значение ещё нужно проверить, оно остаётся в ожидании.", + "unverifiedNoteOne": "{count} значение ещё нужно проверить, оно остаётся в ожидании.", "unverifiedNoteFew": "Значений, которые ещё нужно проверить и которые остаются в ожидании: {count}.", "unverifiedNoteOther": "Значений, которые ещё нужно проверить и которые остаются в ожидании: {count}.", "finish": "Завершить проверку", "confirmFailed": "Не удалось сохранить проверку. Попробуйте ещё раз.", - "savedToastOne": "1 значение сохранено", + "savedToastOne": "{count} значение сохранено", "savedToastFew": "Сохранено значений: {count}", "savedToastOther": "Сохранено значений: {count}", "nothingSavedToast": "Проверка сохранена. Значения не добавлены.", @@ -13156,7 +13245,11 @@ "biomarkerMerge": "{name} объединил(а) показатели", "medicationIngredientsUpdate": "{name} изменил(а) состав добавки", "auditCCreate": "{name} ответил(а) на вопросы AUDIT-C", - "auditCDelete": "{name} убрал(а) запись AUDIT-C" + "auditCDelete": "{name} убрал(а) запись AUDIT-C", + "cycleFeedCreate": "{name} включил(а) подписку на календарь", + "cycleFeedUpdate": "{name} изменил(а) подписку на календарь", + "cycleFeedRotate": "{name} обновил(а) ссылку подписки на календарь", + "cycleFeedRevoke": "{name} отключил(а) подписку на календарь" }, "section": { "measurements": { @@ -13333,7 +13426,9 @@ "specialty": "С вашего последнего визита к специалисту «{specialty}» {date}", "kind": "С вашего последнего визита того же вида ({kind}) {date}", "default": "Последние {days} дней", - "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом." + "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.", + "capped": "Последние два года", + "cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет." } }, "checkups": { @@ -13497,7 +13592,7 @@ "title": "Ваши симптомы", "log": "Записать", "loadError": "Ваши симптомы не удалось загрузить.", - "summaryOne": "Один раз за 30 дней, {max}/10, последний {date}", + "summaryOne": "{count} раз за 30 дней, {max}/10, последний {date}", "summaryFew": "{count} раза за 30 дней, до {max}/10, последний {date}", "summaryOther": "{count} раз за 30 дней, до {max}/10, последний {date}", "summaryNoneRecent": "Не за последние 30 дней, последний {date}", @@ -13510,7 +13605,7 @@ "deleteBody": "«{symptom}» и каждая его запись будут удалены. Чтобы сохранить историю, скройте его вместо этого.", "deleteEventTitle": "Удалить запись?", "deleteEventBody": "Эта запись будет удалена.", - "hiddenOne": "Скрыто (1)", + "hiddenOne": "Скрыто ({count})", "hiddenFew": "Скрыто ({count})", "hiddenOther": "Скрыто ({count})", "recent": "Последние 30 дней", @@ -13577,14 +13672,14 @@ "valuesAllFew": "Все {count} значения", "valuesAllOther": "Все {count} значений", "valuesFewer": "Меньше значений", - "countEntriesOne": "1 запись", + "countEntriesOne": "{count} запись", "countEntriesFew": "Записей: {count}", "countEntriesOther": "Записей: {count}", - "meanOfReadingsOne": "Среднее по 1 измерению", + "meanOfReadingsOne": "Среднее по {count} измерению", "meanOfReadingsFew": "Среднее по {count} измерениям", "meanOfReadingsOther": "Среднее по {count} измерениям", "sinceLastVisit": { - "countOne": "1 запись", + "countOne": "{count} запись", "countFew": "Записей: {count}", "countOther": "Записей: {count}", "allOne": "Показать все", @@ -13773,7 +13868,7 @@ }, "noValue": "нет значения", "bloodPressure": "АД", - "readingsOne": "1 измерение", + "readingsOne": "{count} измерение", "readingsFew": "среднее по {count} измерениям", "readingsOther": "среднее по {count} измерениям" }, @@ -13806,7 +13901,7 @@ "empty": "Нет записей с {from} по {to}", "emptySingle": "Нет записей в {period}", "ended": "{label} завершено", - "afterDaysOne": "через 1 день", + "afterDaysOne": "через {count} день", "afterDaysFew": "через {count} дня", "afterDaysOther": "через {count} дней", "notable": { @@ -13830,7 +13925,7 @@ "apply": "Применить" }, "item": { - "labValuesOne": "1 результат анализа", + "labValuesOne": "{count} результат анализа", "labValuesFew": "{count} результата анализов", "labValuesOther": "{count} результатов анализов" }, @@ -13849,7 +13944,17 @@ }, "legendLabValue": "Результат анализа, как измерено", "legendLabOutside": "Вне референсного диапазона", - "legendLabRange": "Референсный диапазон" + "legendLabRange": "Референсный диапазон", + "legendSymptom": "Линия симптома: наибольшая сила за каждый отмеченный день (от 0 до 10), усреднённая по отмеченным дням", + "symptoms": { + "menuTitle": "Симптомы", + "full": "До {max} симптомов одновременно. Уберите один, чтобы добавить другой.", + "hidden": "скрыт", + "unnamed": "Симптом", + "daysOne": "за {count} день", + "daysFew": "среднее за {count} дня", + "daysOther": "среднее за {count} дней" + } }, "lifeEvents": { "title": "Событие жизни", diff --git a/package.json b/package.json index 0d9e2c80e..1323ed8a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.47.0", + "version": "1.47.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48d7a9ff6..aace7cce0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,12 +12,13 @@ overrides: dompurify@<3.4.17: ^3.4.17 postcss@<8.5.23: ^8.5.23 nanoid@<3.3.18: ^3.3.18 - '@hono/node-server@<1.19.15': ^1.19.15 + '@hono/node-server@<2.1.3': ^2.1.3 picomatch@<4.0.4: ^4.0.4 body-parser@<2.4.0: ^2.4.0 source-map-js@<1.2.2: ^1.2.2 postcss-selector-parser@<7.1.6: ^7.1.6 smol-toml@<1.9.0: ^1.9.0 + systeminformation@<5.33.16: ^5.33.16 qs@<6.16.0: ^6.16.0 fast-uri@<3.1.9: ^3.1.9 flatted@<3.4.2: ^3.4.2 @@ -870,9 +871,9 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.1.6': + resolution: {integrity: sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==} + engines: {node: '>=20'} peerDependencies: hono: ^4.13.11 @@ -6679,8 +6680,8 @@ packages: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} - systeminformation@5.33.15: - resolution: {integrity: sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==} + systeminformation@5.33.19: + resolution: {integrity: sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==} engines: {node: '>=10.0.0'} os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true @@ -7925,7 +7926,7 @@ snapshots: object-treeify: 1.1.33 open: 8.4.2 picomatch: 4.0.7 - systeminformation: 5.33.15 + systeminformation: 5.33.19 undici: 7.30.1 which: 4.0.0 yocto-spinner: 1.2.2 @@ -8079,7 +8080,7 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.17(hono@4.13.13)': + '@hono/node-server@2.1.6(hono@4.13.13)': dependencies: hono: 4.13.13 @@ -8374,7 +8375,7 @@ snapshots: '@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.13) + '@hono/node-server': 2.1.6(hono@4.13.13) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -14105,7 +14106,7 @@ snapshots: svg-pathdata@6.0.3: optional: true - systeminformation@5.33.15: {} + systeminformation@5.33.19: {} tagged-tag@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f716b3daf..0d63e5b6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,13 +22,13 @@ overrides: # bump; the fix landed in 8.5.23. "postcss@<8.5.23": "^8.5.23" "nanoid@<3.3.18": "^3.3.18" - # GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. Mirrored for - # /opt/prisma-cli in the Dockerfile, which is where the flagged 1.19.11 - # actually landed: prisma pulls it through @prisma/dev, and that install - # reads npm `overrides`, not this file. The bound stays on the 1.x line - # because the advisory is fixed there too and 2.x is a major nothing asked - # for. - "@hono/node-server@<1.19.15": "^1.19.15" + # GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. The + # serveStatic double decode fixed in 2.1.3 has no fix on the 1.x line, so + # the bound moves to 2.x, which the MCP SDK accepts (`^1.19.9 || ^2.0.5`). + # The app never calls serveStatic; the copy sits on the MCP path all the + # same. Mirrored for /opt/prisma-cli in the Dockerfile, where an older + # @prisma/dev once pulled it in. + "@hono/node-server@<2.1.3": "^2.1.3" "picomatch@<4.0.4": "^4.0.4" # CVE-2026-111181 (HIGH) raised the floor from 2.3.0. The copy arrives # under express in the MCP SDK, on a production path. @@ -40,6 +40,9 @@ overrides: "source-map-js@<1.2.2": "^1.2.2" "postcss-selector-parser@<7.1.6": "^7.1.6" "smol-toml@<1.9.0": "^1.9.0" + # Command injection through services() on Windows, under shadcn's CLI at + # dev time only. 5.33.16 has the fix. + "systeminformation@<5.33.16": "^5.33.16" # Array-limit bypass through bracket-key comma parsing, and a denial of # service through an attacker-controlled isBuffer. 6.16.0 covers both. "qs@<6.16.0": "^6.16.0" diff --git a/prisma/migrations/0493_basal_energy_burned/migration.sql b/prisma/migrations/0493_basal_energy_burned/migration.sql new file mode 100644 index 000000000..93b642ba3 --- /dev/null +++ b/prisma/migrations/0493_basal_energy_burned/migration.sql @@ -0,0 +1,10 @@ +-- Extend `measurement_type` with resting energy, which sat in the deferred +-- list until now: +-- +-- BASAL_ENERGY_BURNED <- HKQuantityTypeIdentifierBasalEnergyBurned (kcal) +-- +-- Purely additive: no existing row carries the value. IF NOT EXISTS keeps a +-- replay on a partially applied database harmless. Enum values cannot be +-- dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BASAL_ENERGY_BURNED'; diff --git a/prisma/migrations/0494_cycle_calendar_feed/migration.sql b/prisma/migrations/0494_cycle_calendar_feed/migration.sql new file mode 100644 index 000000000..64d0c4c84 --- /dev/null +++ b/prisma/migrations/0494_cycle_calendar_feed/migration.sql @@ -0,0 +1,85 @@ +-- Subscribable cycle forecast (#1350): one table holding the calendar-feed +-- credential of a record, and the enum for how its events are titled. +-- +-- The row is a URL credential. Only the HMAC of the raw `hlf_` token is +-- stored; the forecast itself is computed on every fetch and never persisted. +-- Revocation stamps `revoked_at` and never deletes, so the uniqueness that +-- matters (at most one LIVE feed per record) is a partial index, the shape +-- `account_grants_live_pair_key` uses. Prisma cannot express it, so it lives +-- here only. +-- +-- Both foreign keys cascade: deleting the record ends its feed, and deleting +-- the carrier who minted it does too, so a guardian's account going away +-- cannot leave a link behind that nobody can revoke. +-- +-- One DO block, so the file is one statement that rolls back whole and the +-- foreign keys' lock wait on `users` is bounded. Only the new table and the +-- new type are created; nothing existing changes shape. Reversibility: +-- dropping the table and the type ends every feed URL; subscribers see 404 and +-- the owner mints again. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_title_mode' + ) THEN + CREATE TYPE "cycle_calendar_feed_title_mode" AS ENUM ('DISCREET', 'DESCRIPTIVE'); + END IF; + + CREATE TABLE IF NOT EXISTS "cycle_calendar_feeds" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_by_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "token_prefix" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + "last_fetched_at" TIMESTAMP(3), + "fetch_count" INTEGER NOT NULL DEFAULT 0, + "last_fetcher_ua" TEXT, + "body_hash" TEXT, + "content_changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "horizon_cycles" INTEGER NOT NULL DEFAULT 3, + "history_cycles" INTEGER NOT NULL DEFAULT 0, + "include_fertile" BOOLEAN NOT NULL DEFAULT false, + "title_mode" "cycle_calendar_feed_title_mode" NOT NULL DEFAULT 'DISCREET', + + CONSTRAINT "cycle_calendar_feeds_pkey" PRIMARY KEY ("id"), + -- The ranges the options route validates, held again by the table so a + -- write that bypassed the route cannot widen the horizon. + CONSTRAINT "cycle_calendar_feeds_horizon_check" + CHECK ("horizon_cycles" BETWEEN 1 AND 6), + CONSTRAINT "cycle_calendar_feeds_history_check" + CHECK ("history_cycles" BETWEEN 0 AND 12) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_token_hash_key" + ON "cycle_calendar_feeds" ("token_hash"); + + CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_user_id_created_at_idx" + ON "cycle_calendar_feeds" ("user_id", "created_at" DESC); + + CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_created_by_id_idx" + ON "cycle_calendar_feeds" ("created_by_id"); + + -- At most one live feed per record. Revoked rows fall out of the index, so a + -- rotate (revoke, then mint, in one transaction) never collides with itself. + CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_live_user_key" + ON "cycle_calendar_feeds" ("user_id") + WHERE "revoked_at" IS NULL; + + ALTER TABLE "cycle_calendar_feeds" + ADD CONSTRAINT "cycle_calendar_feeds_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + + ALTER TABLE "cycle_calendar_feeds" + ADD CONSTRAINT "cycle_calendar_feeds_created_by_id_fkey" + FOREIGN KEY ("created_by_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 950f86b99..8b102b41c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1166,6 +1166,12 @@ model User { // v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + // #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the + // record the feed serves; `cycleCalendarFeedsMinted` is the carrier who + // minted it, which is the owner on their own record and the guardian on a + // managed profile. + cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject") + cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator") // v1.39 — the needs-based onboarding's answers and step ledger for THIS // record. 1:1, and keyed by record rather than by the person at the @@ -1807,6 +1813,9 @@ enum MeasurementType { STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source. TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL). ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL). + // v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED, + // never folded into it and never summed with it into a total. + BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM. @@map("measurement_type") } @@ -8325,6 +8334,76 @@ model CycleProfile { @@map("cycle_profiles") } +/// #1350 — how the feed titles its events. DISCREET shows a dot and names the +/// calendar "HealthLog"; DESCRIPTIVE names the period and the calendar. +enum CycleCalendarFeedTitleMode { + DISCREET + DESCRIPTIVE + + @@map("cycle_calendar_feed_title_mode") +} + +/// #1350 — a subscribable calendar feed of one record's cycle forecast. +/// +/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is +/// stored (`hashToken`, the same key as `ApiToken.tokenHash`); the raw token is +/// returned once at mint and is unrecoverable afterwards. The feed is computed +/// live on every fetch from the record's own rows, so nothing about the +/// forecast is stored here. +/// +/// At most one live feed per record: a partial unique index on `user_id WHERE +/// revoked_at IS NULL`, written in the migration because Prisma cannot express +/// it. Revocation stamps `revokedAt` and never deletes, so the status read can +/// say when a link stopped working. Not in the backup: a restore would bring +/// back a link the owner revoked (see `backup-plan.ts`). +model CycleCalendarFeed { + id String @id @default(cuid()) + /// The record whose forecast the feed serves. + userId String @map("user_id") + /// Who minted it: the owner, or the guardian of a managed profile. A + /// guardian's feed is re-checked against their live MANAGE grant and the + /// managed-profile marker on every fetch. + createdById String @map("created_by_id") + + /// HMAC-SHA256 of the raw `hlf_` token. + tokenHash String @unique @map("token_hash") + /// The first 8 characters of the raw token, shown in settings so the owner + /// can tell which link is live. Not enough to rebuild it. + tokenPrefix String @map("token_prefix") + + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + + /// Fetch statistics, written fire-and-forget by the feed route and read by + /// the status endpoint ("last fetched 3 h ago by Google Calendar"). + lastFetchedAt DateTime? @map("last_fetched_at") + fetchCount Int @default(0) @map("fetch_count") + /// The last fetcher's User-Agent, truncated to 80 characters. + lastFetcherUa String? @map("last_fetcher_ua") + + /// SHA-256 of the last served body without its timestamps. When a fetch + /// builds a different body, `contentChangedAt` moves; both drive DTSTAMP, + /// LAST-MODIFIED and the ETag. + bodyHash String? @map("body_hash") + contentChangedAt DateTime @default(now()) @map("content_changed_at") + + /// Forecast cycles ahead (1..6). The feed caps itself further once the + /// band grows past ±10 days or half a cycle. + horizonCycles Int @default(3) @map("horizon_cycles") + /// Observed cycles included as history (0..12). + historyCycles Int @default(0) @map("history_cycles") + /// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only. + includeFertile Boolean @default(false) @map("include_fertile") + titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode") + + user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade) + createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade) + + @@index([userId, createdAt(sort: Desc)]) + @@index([createdById]) + @@map("cycle_calendar_feeds") +} + /// One row per menstrual cycle (a period-start anchor + derived span). /// `isPredicted` rows are forward-minted forecasts; observed rows are /// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the diff --git a/public/sw.js b/public/sw.js index 5faf97925..77dec3db2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.47.0"; + /* @sw-version-fallback */ "v1.47.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index c5a37c7c6..c2e451db8 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -1824,7 +1824,11 @@ export async function seed() { { analyte: "Ferritin", value: 88, unit: "ng/mL", low: 30, high: 400 }, ]; - const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => { + const insertQuant = async ( + panel: string | null, + lab: QuantLab, + takenAt: Date, + ) => { await client.query( `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`, @@ -1843,7 +1847,11 @@ export async function seed() { ], ); }; - const insertQual = async (panel: string, lab: QualLab, takenAt: Date) => { + const insertQual = async ( + panel: string | null, + lab: QualLab, + takenAt: Date, + ) => { await client.query( `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL, NULL, $7, 'MANUAL', NOW(), NOW())`, @@ -1851,14 +1859,17 @@ export async function seed() { ); }; + // No panel on the readings: a draw label ("Annual blood panel") would + // become one category holding every marker, and the labs list would stay + // flat. Without one each marker lands in its catalog category by name. for (const lab of recentQuant) { - await insertQuant("Annual blood panel", lab, recentPanelAt); + await insertQuant(null, lab, recentPanelAt); } for (const lab of recentQual) { - await insertQual("Annual blood panel", lab, recentPanelAt); + await insertQual(null, lab, recentPanelAt); } for (const lab of olderQuant) { - await insertQuant("Blood panel (last year)", lab, olderPanelAt); + await insertQuant(null, lab, olderPanelAt); } const labResults = [...recentQuant, ...recentQual, ...olderQuant]; @@ -2901,7 +2912,7 @@ export async function seed() { for (const panel of historyPanels) { for (const [key, value] of Object.entries(panel.values)) { await insertQuant( - "Blood panel", + null, { ...panelLabs[key], value }, daysAgoAt(panel.n, 9, 0), ); diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 648ccae7d..c62089f7d 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -9,8 +9,8 @@ import { DERIVED_MODELS, NOT_IN_BACKUP_MODELS, RAW_SQL_TABLES, - backupVerdict, } from "@/lib/export/backup-plan"; +import { backupVerdict } from "@/lib/export/backup-verdict"; /** * Every model in the schema has a written verdict about the backup. diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ff9370204..1770cb655 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,13 +1,13 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94309, + "total": 94047, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/insights/coach-panel/chat-bubble.tsx": 822, - "components/insights/coach-panel/coach-conversation.tsx": 1286, + "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, @@ -17,7 +17,7 @@ "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, - "components/records/life-circumstances-manager.tsx": 1080, + "components/records/life-circumstances-manager.tsx": 828, "components/timeline/timeline-chart.tsx": 1093, "components/timeline/timeline-geometry.ts": 1288, "lib/ai/anthropic-client.ts": 804, @@ -26,8 +26,8 @@ "lib/ai/coach/persistence.ts": 1218, "lib/ai/coach/results/metric-table-tool.ts": 1102, "lib/ai/coach/snapshot.ts": 1346, - "lib/ai/coach/stream-events.ts": 817, - "lib/ai/coach/types.ts": 1011, + "lib/ai/coach/stream-events.ts": 811, + "lib/ai/coach/types.ts": 1008, "lib/ai/codex-client.ts": 907, "lib/ai/prompts/insight-generator.ts": 1115, "lib/ai/provider.ts": 1628, @@ -42,7 +42,7 @@ "lib/dashboard-layout.ts": 976, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, - "lib/export/backup-plan.ts": 853, + "lib/export/backup-plan.ts": 838, "lib/export/coach-backup.ts": 1070, "lib/export/profile-backup.ts": 1019, "lib/fitbit/client.ts": 1262, @@ -86,6 +86,6 @@ "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, - "proxy.ts": 870 + "proxy.ts": 846 } } diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f96e8db4a..dd83b4bdc 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,18 +1,17 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 345, - "total": 111894, + "count": 344, + "total": 111520, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, "app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206, "app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211, - "app/api/auth/login/route.ts :: apiHandler(arg)": 262, + "app/api/auth/login/route.ts :: apiHandler(arg)": 260, "app/api/auth/me/route.ts :: apiHandler(arg)": 378, "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258, - "app/api/auth/register/route.ts :: apiHandler(arg)": 268, + "app/api/auth/register/route.ts :: apiHandler(arg)": 266, "app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309, - "app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230, "app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213, "app/api/cycle/insights/route.ts :: apiHandler(arg)": 226, "app/api/devices/route.ts :: apiHandler(arg)": 226, @@ -24,7 +23,7 @@ "app/api/integrations/status/route.ts :: apiHandler(arg)": 476, "app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239, "app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280, - "app/api/labs/route.ts :: postLabResult": 242, + "app/api/labs/route.ts :: postLabResult": 241, "app/api/mcp/oauth/token/route.ts :: POST": 250, "app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248, "app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239, @@ -81,7 +80,7 @@ "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, "components/cycle/cycle-settings.tsx :: CycleSettings": 228, - "components/cycle/cycle-view.tsx :: CycleView": 408, + "components/cycle/cycle-view.tsx :: CycleView": 407, "components/cycle/log-day-sheet.tsx :: LogDaySheet": 753, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, @@ -103,7 +102,7 @@ "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, "components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, - "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1011, + "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212, "components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317, @@ -130,16 +129,15 @@ "components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354, "components/insights/sub-page-shell.tsx :: SubPageShell": 266, "components/insights/trends-row.tsx :: TrendsRow": 215, - "components/labs/biomarker-form.tsx :: BiomarkerForm": 244, + "components/labs/biomarker-form.tsx :: BiomarkerForm": 229, "components/labs/biomarker-manager.tsx :: BiomarkerManager": 242, - "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250, + "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239, "components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370, "components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481, "components/labs/lab-form.tsx :: LabForm": 473, "components/labs/lab-history-list.tsx :: LabHistoryList": 534, - "components/labs/lab-list.tsx :: LabList": 308, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, - "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 386, + "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378, "components/layout/auth-shell.tsx :: AuthShell": 518, "components/layout/bottom-nav.tsx :: BottomNav": 262, "components/layout/capture-picker.tsx :: CapturePicker": 235, @@ -180,14 +178,14 @@ "components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257, "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255, "components/onboarding/done-screen.tsx :: DoneScreen": 408, - "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425, + "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423, "components/onboarding/tour.tsx :: OnboardingTour": 544, "components/practitioners/practitioner-list.tsx :: PractitionerList": 230, "components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363, "components/records/allergy-form.tsx :: AllergyForm": 225, "components/records/audit-c-panel.tsx :: AuditCPanel": 285, "components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201, - "components/records/life-circumstances-manager.tsx :: SectionBody": 482, + "components/records/life-circumstances-manager.tsx :: SectionBody": 472, "components/settings/about-section.tsx :: AboutSection": 236, "components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311, "components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235, @@ -207,7 +205,7 @@ "components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293, "components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458, "components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255, - "components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230, + "components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227, "components/settings/integrations/document-sources-card.tsx :: SourceForm": 306, "components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300, "components/settings/integrations/integration-card.tsx :: IntegrationCard": 209, @@ -243,11 +241,11 @@ "hooks/use-sheet-drag.ts :: createSheetDrag": 312, "lib/activity/calendar-math.ts :: buildActivityCalendar": 204, "lib/ai/anthropic-client.ts :: generateCompletion": 294, - "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218, + "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202, "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, - "lib/ai/coach/turn/model.ts :: runTurnModel": 538, + "lib/ai/coach/turn/model.ts :: runTurnModel": 534, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, "lib/ai/coach/turn/reply-guards.ts :: guardReply": 331, "lib/ai/codex-client.ts :: parseResponse": 302, @@ -286,8 +284,8 @@ "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, - "lib/import/labs-csv.ts :: importLabsCsv": 309, - "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, + "lib/import/labs-csv.ts :: importLabsCsv": 307, + "lib/import/labs-csv.ts :: importLabsCsv > flush": 229, "lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217, "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, @@ -300,7 +298,7 @@ "lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292, "lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222, "lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243, - "lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239, + "lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238, "lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276, "lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282, "lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252, @@ -315,7 +313,7 @@ "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592, "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 587, "lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324, - "lib/labs/biomarker-merge.ts :: runMerge": 254, + "lib/labs/biomarker-merge.ts :: runMerge": 232, "lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278, "lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263, "lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539, @@ -336,7 +334,7 @@ "lib/notifications/senders/web-push.ts :: sendViaWebPush": 268, "lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239, "lib/personal-records/pr-direction.ts :: getPRDirection": 208, - "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272, + "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, "lib/strava/sync.ts :: runStravaSync": 219, "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 330, @@ -347,6 +345,6 @@ "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 337, - "proxy.ts :: proxy": 498 + "proxy.ts :: proxy": 471 } } diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index f89a99ecd..fc64b4a07 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2894, - "total": 9223, + "count": 2892, + "total": 9220, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1072,7 +1072,7 @@ "components/labs/lab-biomarker-detail.tsx": 13, "components/labs/lab-form.tsx": 6, "components/labs/lab-history-list.tsx": 4, - "components/labs/lab-list.tsx": 6, + "components/labs/lab-list.tsx": 5, "components/labs/lab-reference-range-bar.tsx": 1, "components/labs/lab-trend-card.tsx": 1, "components/labs/lab-trend-sparkline.tsx": 1, @@ -2892,7 +2892,7 @@ "lib/withings/sync.ts": 6, "lib/withings/webhook-handler.ts": 3, "lib/workouts/list-read.ts": 1, - "proxy.ts": 29, + "proxy.ts": 27, "types/analytics.ts": 8, "types/backups.ts": 1 } diff --git a/src/__tests__/cycle-feed-content-guard.test.ts b/src/__tests__/cycle-feed-content-guard.test.ts new file mode 100644 index 000000000..d250d0116 --- /dev/null +++ b/src/__tests__/cycle-feed-content-guard.test.ts @@ -0,0 +1,185 @@ +/** + * #1350 — the calendar feed carries dates and a title, and nothing else. + * + * A calendar provider keeps everything it fetches, beyond the reach of any + * revoke. So this seeds every kind of day-log content the record can hold — + * a note, custom symptom labels, a basal temperature, an ovulation test, the + * mucus and cervix signs, intercourse, contraception, a pregnancy test — as + * recognisable sentinels on the very rows the feed reads, as if a select had + * been widened by mistake, and asserts that no rendered body contains any of + * them, in either title mode, with history and the fertile window on. + * + * Written, then broken once on purpose (a description that interpolated the + * day's note) to prove it fails. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { addDays } from "@/lib/cycle/day-math"; +import { buildCycleFeed } from "@/lib/cycle/feed/build"; +import { renderIcs } from "@/lib/cycle/ics"; + +import { + FEED, + SUBJECT, + profileRow, + regularCycles, +} from "@/lib/cycle/feed/__tests__/fixtures"; + +const db = prisma as unknown as { + cycleProfile: { findUnique: ReturnType }; + menstrualCycle: { findMany: ReturnType }; + cycleDayLog: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; +}; + +const SENTINELS = { + note: "SENTINEL-NOTE-zebra", + noteEncrypted: "SENTINEL-CIPHER-okapi", + symptom: "SENTINEL-SYMPTOM-quokka", + symptomKey: "sentinel_symptom_key", + bbt: "36.987", + wrist: "35.4321", + opk: "POSITIVE_LH_SURGE", + mucus: "EGG_WHITE", + cervix: "HIGH", + sex: "SENTINEL-SEX-tapir", + contraceptive: "SENTINEL-CONTRACEPTIVE-ibis", + pregnancyTest: "SENTINEL-PREGNANCY-lynx", + userId: "subject", +}; + +const FIRST = "2025-11-18"; +const NOW = new Date("2026-11-06T12:00:00.000Z"); + +function stuffedDayLogs() { + const cycles = regularCycles(FIRST); + const out = []; + for (const c of cycles) { + for (let i = 0; i < 6; i++) { + out.push({ + id: `log-${c.startDate}-${i}`, + userId: SENTINELS.userId, + date: addDays(c.startDate, i), + flow: i < 4 ? "MEDIUM" : "NONE", + basalBodyTempC: Number(SENTINELS.bbt), + temperatureExcluded: false, + ovulationTest: SENTINELS.opk, + cervicalMucus: SENTINELS.mucus, + cervixPosition: SENTINELS.cervix, + cervixFirmness: null, + cervixOpening: null, + intermenstrualBleeding: true, + sexualActivity: SENTINELS.sex, + protectedSex: false, + pregnancyTest: SENTINELS.pregnancyTest, + progesteroneTest: SENTINELS.pregnancyTest, + contraceptive: SENTINELS.contraceptive, + notes: SENTINELS.note, + notesEncrypted: SENTINELS.noteEncrypted, + sensitiveEncrypted: SENTINELS.noteEncrypted, + symptomLinks: [ + { + symptom: { + key: SENTINELS.symptomKey, + label: SENTINELS.symptom, + }, + }, + ], + _count: { symptomLinks: 1 }, + }); + } + } + return out; +} + +beforeEach(() => { + vi.clearAllMocks(); + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("TRYING_TO_CONCEIVE"), + ); + db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST)); + db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs()); + db.measurement.findMany.mockResolvedValue([ + { + measuredAt: new Date("2026-11-01T03:00:00Z"), + value: Number(SENTINELS.wrist), + }, + ]); +}); + +describe("the calendar feed body", () => { + it.each(["DISCREET", "DESCRIPTIVE"] as const)( + "carries no day-log content in %s mode", + async (titleMode) => { + const built = await buildCycleFeed( + { + ...FEED, + titleMode, + historyCycles: 12, + horizonCycles: 6, + includeFertile: true, + }, + SUBJECT, + NOW, + ); + // Non-empty, or the assertion below would hold for nothing. + expect(built.calendar.events.length).toBeGreaterThan(10); + const body = renderIcs(built.calendar, NOW); + for (const [name, value] of Object.entries(SENTINELS)) { + expect(body, `${name} leaked into the feed`).not.toContain(value); + } + }, + ); + + it("carries only the properties an all-day date event needs", async () => { + const built = await buildCycleFeed( + { + ...FEED, + titleMode: "DESCRIPTIVE", + historyCycles: 6, + includeFertile: true, + }, + SUBJECT, + NOW, + ); + const body = renderIcs(built.calendar, NOW); + const properties = new Set( + body + .split("\r\n") + .filter((line) => line && !line.startsWith(" ")) + .map((line) => line.split(/[:;]/)[0]), + ); + expect([...properties].sort()).toEqual( + [ + "BEGIN", + "CALSCALE", + "CLASS", + "DESCRIPTION", + "DTEND", + "DTSTAMP", + "DTSTART", + "END", + "LAST-MODIFIED", + "METHOD", + "PRODID", + "REFRESH-INTERVAL", + "SUMMARY", + "TRANSP", + "UID", + "VERSION", + "X-PUBLISHED-TTL", + "X-WR-CALNAME", + ].sort(), + ); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 720f1be30..1dd48b750 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "tombstoned-no-restore", confirm: ["ConfirmButton", "ConfirmDialog"], }, + { + file: "components/cycle/use-cycle-feed.ts", + destroys: + "the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link", + triggers: ["components/cycle/cycle-calendar-feed-card.tsx"], + recovery: "permanent", + confirm: ["ConfirmButton"], + }, // ── Medications ───────────────────────────────────────────────────────── { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 0845b7580..52bf07926 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(110); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90); + expect(ALL_LEAF_IDS.length).toBe(111); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 9ed7c49f2..70faa52e6 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean { * reason. Prune an entry only when the value genuinely diverges in a locale. */ const LEGIT_IDENTICAL = new Set([ + // #1350 — "Google Calendar" is the product's own name in the Spanish and + // Italian UI, and "Cycle (HealthLog)" is the French word for the same thing. + "cycle.feed.fetcher.GOOGLE", + "cycle.feed.ics.calendarName", // Vaccine catalogue: clinical proper nouns whose accepted written form is the // same across these locales. "Hib" carries its Latin binomial verbatim, and // "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue diff --git a/src/__tests__/labs-overview-response-consumer-guard.test.ts b/src/__tests__/labs-overview-response-consumer-guard.test.ts new file mode 100644 index 000000000..5f5e5cc2b --- /dev/null +++ b/src/__tests__/labs-overview-response-consumer-guard.test.ts @@ -0,0 +1,166 @@ +/** + * Per-envelope response/consumer guard for the labs categories. + * + * The registered Zod schemas are the producer: `LabOverviewResponse` + * (`GET /api/labs/overview`), `ListBiomarkersResponse` (`GET /api/biomarkers`) + * and `BiomarkerMergeResult` (`POST /api/biomarkers/{id}/merge`). Every leaf + * of the parts this guard owns is walked out of the schema itself and must be + * tied to the web reader that renders it, or to a named exemption with its + * reason. A leaf added to a schema without either fails, so a field cannot + * ship write-only. Matchers are qualified (`category.status`, not `status`), + * so another response's field of the same name cannot satisfy them. + * + * Scope: the category fields and their two companions. The overview's + * `markers` subtree predates this guard and is read through `LabResultDto`; + * it is exempt as a whole, by name. + */ +import { describe, expect, it } from "vitest"; +import type { z } from "zod/v4"; + +import { + biomarkerListResponse, + biomarkerMergeResult, +} from "@/lib/openapi/routes/biomarkers"; +import { labOverviewResponse } from "@/lib/openapi/routes/labs"; + +import { readSource } from "./helpers/source-files"; + +interface Def { + type: string; + shape?: Record; + element?: z.ZodType; + innerType?: z.ZodType; + in?: z.ZodType; +} + +function defOf(schema: z.ZodType): Def { + return (schema as unknown as { _zod: { def: Def } })._zod.def; +} + +/** Leaf paths of a schema: `a.b`, arrays as `a[]`. */ +function leaves(schema: z.ZodType, prefix: string): string[] { + const def = defOf(schema); + if (def.type === "object" && def.shape) { + return Object.entries(def.shape).flatMap(([key, child]) => + leaves(child, prefix ? `${prefix}.${key}` : key), + ); + } + if (def.type === "array" && def.element) { + return leaves(def.element, `${prefix}[]`); + } + const inner = def.innerType ?? def.in; + if (inner) return leaves(inner, prefix); + return [prefix]; +} + +interface Reader { + file: string; + pattern: RegExp; +} + +const LIST = "src/components/labs/lab-list.tsx"; +const GROUPS = "src/components/labs/lab-list-groups.tsx"; +const FORM_PANEL = "src/components/labs/biomarker-panel-field.tsx"; +const MERGE_SHEET = "src/components/labs/biomarker-merge-sheet.tsx"; + +const COUNTS_DESTRUCTURE = (name: string) => + new RegExp(`\\{[^}]*\\b${name}\\b[^}]*\\}\\s*=\\s*category\\.counts\\b`); + +const READERS: Record = { + "overview:grouped": { file: LIST, pattern: /\bdata\?\.grouped\b/ }, + "overview:categories[].key": { file: GROUPS, pattern: /\bcategory\.key\b/ }, + "overview:categories[].label": { + file: GROUPS, + pattern: /\{category\.label\}/, + }, + "overview:categories[].markerKeys[]": { + file: GROUPS, + pattern: /\bof\s+category\.markerKeys\b/, + }, + "overview:categories[].counts.inRange": { + file: GROUPS, + pattern: COUNTS_DESTRUCTURE("inRange"), + }, + "overview:categories[].counts.below": { + file: GROUPS, + pattern: COUNTS_DESTRUCTURE("below"), + }, + "overview:categories[].counts.above": { + file: GROUPS, + pattern: COUNTS_DESTRUCTURE("above"), + }, + "overview:categories[].counts.unknown": { + file: GROUPS, + pattern: COUNTS_DESTRUCTURE("unknown"), + }, + "overview:categories[].status": { + file: GROUPS, + pattern: /\bcategory\.status\s*===\s*"above"/, + }, + "biomarkers:customPanels[]": { + file: FORM_PANEL, + pattern: /\bdata\?\.customPanels\b/, + }, + "merge:adoptedPanel": { + file: MERGE_SHEET, + pattern: /\bpreview\.adoptedPanel\b/, + }, +}; + +/** Leaves this guard does not own, with the reason. Prefix match on `[]`. */ +const EXEMPT: Record = { + "overview:markers[]": + "the per-marker rows predate the categories and are rendered through LabResultDto in lab-list-rows.tsx", +}; + +function ownedLeaves(): string[] { + const overview = leaves(labOverviewResponse, "").map((l) => `overview:${l}`); + const list = leaves(biomarkerListResponse, "") + .filter((l) => l.startsWith("customPanels")) + .map((l) => `biomarkers:${l}`); + const merge = leaves(biomarkerMergeResult, "") + .filter((l) => l === "adoptedPanel") + .map((l) => `merge:${l}`); + return [...overview, ...list, ...merge]; +} + +function isExempt(leaf: string): boolean { + return Object.keys(EXEMPT).some((prefix) => leaf.startsWith(prefix)); +} + +describe("labs categories response/consumer guard", () => { + const owned = ownedLeaves(); + + it("walks the schemas it guards", () => { + // Nine category leaves, `grouped`, the panels and the adopted panel, plus + // the exempt marker rows; far fewer means the walk broke. + expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe( + Object.keys(READERS).length, + ); + expect(owned.filter(isExempt).length).toBeGreaterThan(10); + }); + + it("ties every leaf to a reader or a named exemption", () => { + const orphans = owned.filter((leaf) => !isExempt(leaf) && !READERS[leaf]); + expect(orphans).toEqual([]); + const stale = Object.keys(READERS).filter((leaf) => !owned.includes(leaf)); + expect(stale).toEqual([]); + }); + + for (const [leaf, reader] of Object.entries(READERS)) { + it(`${leaf} is read in ${reader.file}`, () => { + // `readSource` throws when the anchor is gone from the file. + const source = readSource(reader.file, reader.pattern); + expect(reader.pattern.test(source)).toBe(true); + }); + } + + it("cannot pass on a reader that reads nothing", () => { + expect( + COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"), + ).toBe(false); + expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe( + false, + ); + }); +}); diff --git a/src/__tests__/marker-category-coverage.test.ts b/src/__tests__/marker-category-coverage.test.ts new file mode 100644 index 000000000..2e6ce394f --- /dev/null +++ b/src/__tests__/marker-category-coverage.test.ts @@ -0,0 +1,69 @@ +/** + * Every analyte the catalog can recognise by name has a catalog category. + * + * The labs list groups a marker without a panel by the analyte its name + * resolves to (`catalogKeyForName`), and that analyte's panel comes from its + * seed or, for an analyte with no seed, from `ALIAS_PANEL`. An alias key added + * to `ANALYTE_ALIASES` or `LAB_NAME_ALIASES` without an `ALIAS_PANEL` entry + * would put every marker of that analyte under "Other" without a word. This + * holds the lists together. + * + * The check is a function of the alias table, so the guard proves it can + * fail: the same check over the table minus one entry must name that entry. + */ +import { describe, expect, it } from "vitest"; + +import { + ALIAS_PANEL, + ANALYTE_ALIASES, + BIOMARKER_CATALOG, + BIOMARKER_PANELS, + LAB_NAME_ALIASES, + type BiomarkerPanelKey, +} from "@/lib/labs/biomarker-catalog"; + +function uncategorised( + aliasPanel: Readonly>, +): { checked: number; missing: string[] } { + const panels = new Set(BIOMARKER_PANELS); + const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel])); + const keys = new Set([ + ...BIOMARKER_CATALOG.map((s) => s.slug), + ...ANALYTE_ALIASES.map((a) => a.key), + ...LAB_NAME_ALIASES.map((a) => a.key), + ]); + const missing: string[] = []; + for (const key of keys) { + const panel = seedPanel.get(key) ?? aliasPanel[key]; + if (panel === undefined || !panels.has(panel)) missing.push(key); + } + return { checked: keys.size, missing }; +} + +describe("marker category coverage", () => { + it("gives every seed and alias key a catalog panel", () => { + const { checked, missing } = uncategorised(ALIAS_PANEL); + expect(checked).toBeGreaterThan(BIOMARKER_CATALOG.length); + expect(missing).toEqual([]); + }); + + it("lists no alias panel for a key the catalog does not know", () => { + const known = new Set([ + ...ANALYTE_ALIASES.map((a) => a.key), + ...LAB_NAME_ALIASES.map((a) => a.key), + ]); + const seeds = new Set(BIOMARKER_CATALOG.map((s) => s.slug)); + const stale = Object.keys(ALIAS_PANEL).filter( + (key) => !known.has(key) || seeds.has(key), + ); + expect(stale).toEqual([]); + }); + + it("fails when an alias panel entry is removed", () => { + const [dropped] = Object.keys(ALIAS_PANEL); + const reduced = Object.fromEntries( + Object.entries(ALIAS_PANEL).filter(([key]) => key !== dropped), + ); + expect(uncategorised(reduced).missing).toEqual([dropped]); + }); +}); diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index a5898affc..1162d5028 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly> = { "Starts the identity-provider handoff, for the browser and for the native client.", "/api/auth/oidc/status": "Says whether single sign-on is configured, so the sign-in screen can offer it.", + "/api/cycle/feed/{token}": + "A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.", "/api/notifications/vapid": "The instance's public Web Push key. Public by definition.", "/api/auth/claim/preview": diff --git a/src/__tests__/proxy-csp-blob-preview.test.ts b/src/__tests__/proxy-csp-blob-preview.test.ts index 06811925f..c312ced2c 100644 --- a/src/__tests__/proxy-csp-blob-preview.test.ts +++ b/src/__tests__/proxy-csp-blob-preview.test.ts @@ -11,7 +11,7 @@ import { NextRequest } from "next/server"; * - `frame-src` exists and admits precisely 'self' and blob: — nothing else. * - `img-src` admits blob: alongside 'self' and data:. * - The boundaries that make blob: safe stay untouched: `object-src 'none'`, - * `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src. + * `frame-ancestors 'self'`, `default-src 'self'`, nonce-bound script-src. * - The vault SERVE route keeps its own stricter document CSP unchanged. * * Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only @@ -73,7 +73,10 @@ describe("proxy.ts blob preview CSP (#787)", () => { const res = proxy(makeRequest("/documents")); const csp = res.headers.get("content-security-policy") ?? ""; expect(directive(csp, "object-src")).toEqual(["'none'"]); - expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]); + // 'self', never 'none': the blob: preview inherits this policy, and + // WebKit applies the inherited frame-ancestors to the preview's own + // parent, so 'none' blanks every PDF preview in Safari. + expect(directive(csp, "frame-ancestors")).toEqual(["'self'"]); expect(directive(csp, "default-src")).toEqual(["'self'"]); expect(csp).toMatch(/script-src 'self' 'nonce-/); }); diff --git a/src/__tests__/proxy-cycle-feed-public.test.ts b/src/__tests__/proxy-cycle-feed-public.test.ts new file mode 100644 index 000000000..4c9faa620 --- /dev/null +++ b/src/__tests__/proxy-cycle-feed-public.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * #1350 — the cycle calendar feed at `/api/cycle/feed/.ics`. + * + * A calendar provider polls it from its own servers with no cookie, so the + * proxy must pass the request through untouched, keep it out of indexes and + * referrers, and leave its caching to the route (`private, no-cache` plus an + * ETag) rather than stamping `no-store` over the validator path. The + * management routes beside it stay cookie-only and are not on the list. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { PUBLIC_PATHS, proxy } from "../proxy"; + +// Built at run time so no token-shaped literal sits in the source. +const TOKEN = `hlf_${"7d".repeat(24)}`; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("proxy on /api/cycle/feed/", () => { + it("lists the feed prefix, trailing slash included, and not the management namespace", () => { + expect(PUBLIC_PATHS).toContain("/api/cycle/feed/"); + expect( + PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)), + ).toBe(false); + }); + + it("passes a cookie-less GET through to the route", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, { + headers: { "user-agent": "Google-Calendar-Importer" }, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-next")).toBe("1"); + expect(res.headers.get("location")).toBeNull(); + }); + + it("keeps the token out of indexes and referrers", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("leaves the route's Cache-Control alone, so the ETag path survives", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => { + vi.stubEnv("DEMO_MODE", "true"); + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.status).toBe(200); + }); + + it("refuses a demo-mode mint at the edge", () => { + vi.stubEnv("DEMO_MODE", "true"); + const res = proxy( + new NextRequest("http://localhost/api/cycle/feed-subscription", { + method: "POST", + }), + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/__tests__/proxy-document-serve-framing.test.ts b/src/__tests__/proxy-document-serve-framing.test.ts index a0353575c..cb1b7f263 100644 --- a/src/__tests__/proxy-document-serve-framing.test.ts +++ b/src/__tests__/proxy-document-serve-framing.test.ts @@ -4,8 +4,7 @@ import { NextRequest } from "next/server"; /** * The document vault's decrypt-and-serve route is framed same-origin by the * detail preview (`