Compare commits

..
211 Commits
Author SHA1 Message Date
dependabot[bot] 0eadcdc326 build(deps): bump the production-weekly group across 1 directory with 7 updates (#1354)
Bumps the production-weekly group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@parse/node-apn](https://github.com/parse-community/node-apn) | `8.1.1` | `8.2.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.105.1` | `5.105.3` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.25` | `3.14.26` |
| [jose](https://github.com/panva/jose) | `6.2.14` | `6.2.15` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.66.0` | `1.67.0` |
| [mmdb-lib](https://github.com/runk/mmdb-lib) | `3.0.3` | `3.0.4` |
| [otpauth](https://github.com/hectorm/otpauth) | `9.5.2` | `9.6.0` |



Updates `@parse/node-apn` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/parse-community/node-apn/releases)
- [Changelog](https://github.com/parse-community/node-apn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/parse-community/node-apn/compare/8.1.1...8.2.0)

Updates `@tanstack/react-query` from 5.105.1 to 5.105.3
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.3/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.25 to 3.14.26
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.26/packages/react-virtual)

Updates `jose` from 6.2.14 to 6.2.15
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.14...v6.2.15)

Updates `lucide-react` from 1.66.0 to 1.67.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.67.0/packages/lucide-react)

Updates `mmdb-lib` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/runk/mmdb-lib/releases)
- [Commits](https://github.com/runk/mmdb-lib/compare/v3.0.3...v3.0.4)

Updates `otpauth` from 9.5.2 to 9.6.0
- [Release notes](https://github.com/hectorm/otpauth/releases)
- [Commits](https://github.com/hectorm/otpauth/compare/v9.5.2...v9.6.0)

---
updated-dependencies:
- dependency-name: "@parse/node-apn"
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: jose
  dependency-version: 6.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: mmdb-lib
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: otpauth
  dependency-version: 9.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 03:46:18 +00:00
Marc-André Bombeck 5e1c39438f Merge pull request #1359 from MBombeck/fix/next-16.4.5
build(deps): move Next.js to 16.4.5
2026-11-07 04:19:53 +01:00
Marc-André Bombeck ce32b0ef89 Merge remote-tracking branch 'origin/main' into fix/next-16.4.5
# Conflicts:
#	pnpm-lock.yaml
2026-11-07 04:03:17 +01:00
Marc-André Bombeck 73b9544f83 test(e2e): check the PDF trailer instead of Content-Length
Next 16.4.5 drops content-length, content-encoding and transfer-encoding
from every route handler response, so the header the export route sets
never reaches the browser. A truncated body still loses its %%EOF
trailer, and that is what the spec now looks for.
2026-11-07 03:37:26 +01:00
dependabot[bot] 5793db9f2a build(deps-dev): bump eslint-config-next from 16.4.3 to 16.4.5 (#1358)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.4.3 to 16.4.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:23:50 +00:00
dependabot[bot] cef48d20f0 build(deps-dev): bump @next/bundle-analyzer from 16.4.3 to 16.4.5 (#1357)
Bumps [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) from 16.4.3 to 16.4.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/next-bundle-analyzer)

---
updated-dependencies:
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:18:09 +00:00
dependabot[bot] a91689a0be build(deps): bump the codeql-action group with 3 updates (#1351)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

Updates `github/codeql-action/analyze` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

Updates `github/codeql-action/upload-sarif` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:04:41 +00:00
Marc-André Bombeck bee4ac4f73 build(deps): move Next.js to 16.4.5
16.4.5 is a security release with seven moderate advisories. Most are about 'use cache' and Cache Components, which the app does not use; one covers self-hosted apps that forward fetched responses.
2026-11-07 02:22:28 +01:00
Marc-André Bombeck 8eb2fb773f Merge pull request #1337 from MBombeck/release/v1.47.1
release/v1.47.1
2026-11-07 02:01:37 +01:00
Marc-André Bombeck 84dace4f89 chore(release): v1.47.1 2026-11-07 01:44:29 +01:00
Marc-André Bombeck 879c92f6ee docs(changelog): note the plural fix 2026-11-07 01:37:23 +01:00
Marc-André Bombeck 4921188f22 fix(i18n): carry the count in One strings where one is more than 1
The plural tier comes from CLDR, and in Russian the one category also
holds 21, 31, 101, while French and Brazilian Portuguese file 0 there.
Thirty One strings in those bundles spelled out the 1, so 21 days read
as one day. They now take {count}, as do two Russian One strings that
spelled the number as a word, and every direct call of such a key passes
the count. A guard finds the locales whose one is wider than 1 from
Intl.PluralRules and holds both ends.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 2adea7052b docs(mcp): name the upcoming periods in the get_cycle description
The cycle read returns the projected period starts after the next one,
each with its band, under upcoming; the tool description for MCP clients
and the Coach did not say so.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 31997e3232 docs(proxy): say that frame-ancestors governs framing
The comments still named X-Frame-Options DENY as what refuses the vault
preview embed and claimed the serve route keeps a sandbox directive. A
browser that reads the CSP's frame-ancestors 'self' ignores
X-Frame-Options, and the document CSP deliberately carries no sandbox.
No behaviour change.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck aea9796170 fix(cycle): raise the calendar feed fetch limits
A link subscribed on a phone, a tablet and a computer at a five-minute
refresh came close to the 30 fetches an hour a link was allowed, and a
household behind one address shared 120. The per-link bucket is now 120
an hour and the per-address one 600. The token is a random secret checked
by its HMAC, so guessing links is not what these limits stop.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 78611277fe fix(report): clamp a long report window in local days
The 730-day clamp subtracted 730 times 24 hours from the end, which lands
an hour off local midnight when an odd number of clock changes lies in
between. The start is now the end's wall clock 730 calendar days back in
the account's zone.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 46a1a5d320 fix(cycle): answer a raced feed rotation with 409 instead of 500
Two rotations at once both revoked the same live feed, and the second
successor then hit the one-live-feed index with an unhandled unique
violation. The losing rotation now gets the mint path's 409
cycle.feed.exists, and the winner's link stands.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 01884e91fa fix(sync): place a start=now cursor a minute behind the present
updatedAt is stamped when a write is built, not when it commits, so a
transaction in flight when the cursor was handed out could commit a row
stamped before it, and a watermark at serverNow skipped that row for
good. The watermarks now sit one minute back; client upserts are
idempotent, so the overlap is harmless. The token's issue time stays
serverNow.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 126bab8c00 fix(labs): preview a CSV text row with the window the write keeps
The dry run showed the printed range beside a text result, which the
write stores on the new marker and not on the reading. The preview and
the write now read the reading's window from one helper, and the
placeholder marker of a dry run takes its bounds through the same rule
as a real mint.
2026-11-07 01:04:26 +01:00
Marc-André Bombeck c7b3f6e4d0 fix(labs): drop a unitless text range when a marker adopts a real unit
A marker minted from a text result that stated no unit kept the printed
numbers as its range, and the first numeric reading then gave the marker
its unit without touching them, so a "30 - 100" printed in ng/mL became a
window in nmol/L. Numbers stated without a unit are dimensionless: they
stay when the adopted unit is the unity "1" and otherwise give way to the
window the first number printed in its own unit, or to none.
2026-11-07 01:04:26 +01:00
Marc-André Bombeck 24dd8a28e3 fix(cycle): keep the wearable repair from reopening a deleted cycle
A cycle the person deleted, or whose first day she deleted or cleared,
left its wearable days without a cycle when nothing lay before them, and
the boot-time repair took them for the pre-fix backlog and reopened it on
every deploy. A day within the hard minimum cycle length after a removed
start is no longer a candidate. The repair also skips accounts with the
cycle module off, as the syncs do, and their rows no longer make a boot
enqueue the job.
2026-11-07 01:04:13 +01:00
Marc-André Bombeck 6c5146942b fix(cycle): stamp a wearable period end only on a cycle it opened
Garmin's period end was written on any cycle starting on Garmin's start
date, a cycle the person opened herself included, even when her own days
ran past it. The stamp now requires the cycle's first day to be the
wearable's own live row.
2026-11-07 01:04:12 +01:00
Marc-André Bombeck 2aa98bb6c2 docs(changelog): note symptom lines on the timeline 2026-11-07 00:42:16 +01:00
Marc-André Bombeck a605a7223f Merge branch 'feat/v1471-981' into release/v1.47.1 2026-11-07 00:41:43 +01:00
Marc-André Bombeck 74e691cecd feat(timeline): draw person-defined symptoms as value lines
A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
2026-11-07 00:38:53 +01:00
Marc-André Bombeck be7a65bdb9 ci(trivy): report only fixable findings from the published-image scan 2026-11-06 20:19:51 +01:00
Marc-André Bombeck a74d2df3c5 docs(changelog): keep the feed's 404 inside its sentence 2026-11-06 20:05:04 +01:00
Marc-André Bombeck 2522a784f9 docs(changelog): lab markers grouped by category and the cycle calendar subscription 2026-11-06 20:04:42 +01:00
Marc-André Bombeck 3b3ef5c0a1 fix(cycle): keep the calendar feed to the owner and a managed profile's guardian
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck e8ef590af7 docs: describe the cycle calendar subscription
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck d9a31524a2 feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck ac31ac40b5 feat(cycle): project the cycles after the next one
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.

`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.

`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck 2480a0138d feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.

The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.

backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck c0470df14c test(labs): tie the category fields to their readers
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.

Refs #1349
2026-11-06 17:57:28 +01:00
Marc-André Bombeck c96e0e0f78 chore(demo): seed lab readings without a draw label as their panel
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
2026-11-06 17:54:02 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00
Marc-André Bombeck 910f28dcfe feat(labs): suggest panels in the marker form and let an edit clear fields
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
2026-11-06 17:51:34 +01:00
Marc-André Bombeck 92638ec089 feat(labs): show the labs list under server-resolved categories
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
2026-11-06 17:49:38 +01:00
Marc-André Bombeck e2170115c7 feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
2026-11-06 17:44:38 +01:00
Marc-André Bombeck 2e14b2d8b5 fix(documents): wait out the upload route's busy answer instead of failing
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".

The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
2026-11-06 12:35:25 +01:00
Marc-André Bombeck 53e5b47e0d fix(today): centre the score's meta lines under the ring
From md up the hero's score column sat on the trailing edge, so
"steady for 4 weeks" and its siblings ended at the ring's right edge
instead of standing under its centre. Below md the lines keep their
full-width row flush with the dial.
2026-11-06 11:26:04 +01:00
Marc-André Bombeck 0882ebd79f fix(timeline): keep a focused point's card when another point loses the pointer
A pointer leaving any point target closed whichever card was open, and a
blur did the same. With the pointer resting on one line's point and the
keyboard on another (the focus scrolls the chart, so the resting pointer
leaves the first point), the focused point lost its card and focusing it
again fired nothing. A point now releases only its own card, and hands it
back to the focused point when there is one.

The lab-marker journey drove into this when the menu click left the
pointer over a value point. It now sets that up on purpose and asserts the
card survives the pointer leaving, in place of the retry that could never
reopen a card on an already focused target.
2026-11-05 17:13:40 +01:00
Marc-André Bombeck 7b4c67b372 feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
2026-11-05 16:04:08 +01:00
Marc-André Bombeck da3d29424d fix(visits): cap the window before a visit at the longest report
The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.

The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck f39598f2e7 feat(coach): name a read past the lookback limit on the trail
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck 2e28d4ed4c fix(query): refetch a query whose first load was in flight at invalidation
TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.

The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
2026-11-05 13:05:57 +01:00
Marc-André Bombeck 63f9d2f60e test(integration): let the server time out a statement before the test does
A statement-level hang in the integration suite surfaced only as a
60-second test timeout, because statement_timeout sat at the same 60 s.
At 45 s the server cancels first, and its error names the statement and
the lock or wait it was stuck on.
2026-11-05 12:01:57 +01:00
Marc-André Bombeck ef61c7d5d1 fix(labs): keep the printed range on a marker minted from a text result
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.

Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.

Refs #1313
2026-11-05 10:59:52 +01:00
Marc-André Bombeck e9886ee554 fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
2026-11-05 00:52:02 +01:00
Marc-André Bombeck 6800ff2620 fix(i18n): translate the stray word in the Russian connect-source hint 2026-11-05 00:00:03 +01:00
Marc-André Bombeck e9ae024035 fix(onboarding): connect a Fitbit answer through Google Health
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.

Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.

The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
2026-11-04 23:59:18 +01:00
Marc-André Bombeck 341061de5c feat(measurements): store resting energy as its own metric
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.

It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.

Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.

Refs #1340
2026-11-04 23:45:55 +01:00
Marc-André Bombeck 4894f5be5b test(labs): seed the marker behind the lab line in the GET contract
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.

Refs #1342
2026-11-04 22:07:34 +01:00
Marc-André Bombeck 27a1cd6c2f feat(integrations): list imported cycle days in the details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
2026-11-04 22:03:18 +01:00
Marc-André Bombeck afdb8163ce fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
2026-11-04 22:03:08 +01:00
Marc-André Bombeck 148af765c9 fix(cycle): open cycles from Garmin and Oura period days
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.

The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.

Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.

Refs #1333
2026-11-04 22:02:48 +01:00
Marc-André Bombeck a35b77c147 feat(labs): serve one marker's lab line under the labs module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.

Refs #1342
2026-11-04 21:24:12 +01:00
Marc-André Bombeck a79fc4168a feat(sync): let a client start the delta feed at the present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
2026-11-04 21:24:12 +01:00
Marc-André Bombeck 59434cd463 feat(healthkit): list the accepted sample types on the integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.

Refs #1340
2026-11-04 21:18:30 +01:00
Marc-André Bombeck 66533497de fix(auth): carry purgeAt on the native OIDC deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
2026-11-04 21:18:30 +01:00
Marc-André Bombeck a812fb699f fix(mental-health): read the country from the region subtag for every language
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.

Refs #1338
2026-11-04 21:02:02 +01:00
Marc-André Bombeck d3401b5b89 refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
2026-11-04 17:59:54 +01:00
Marc-André Bombeck 41f90bac3f chore(deps): move @hono/node-server to 2.x and lift systeminformation
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
2026-11-04 16:56:20 +01:00
Marc-André Bombeck d98e16557b chore(deps): keep Node majors out of the image PRs
Refs #1334
2026-11-04 16:54:29 +01:00
Marc-André Bombeck 40200b55a7 fix(records): let the smoking history fields take keystrokes
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.

Refs #1334
2026-11-04 16:54:14 +01:00
dependabot[bot] 25770912ce build(deps): bump @tanstack/react-query (#1336)
Bumps the production-weekly group with 1 update: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query).


Updates `@tanstack/react-query` from 5.105.0 to 5.105.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.1/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 14:39:22 +00:00
Marc-André Bombeck 90865eb485 Merge release/v1.47.0 2026-11-04 15:04:24 +01:00
Marc-André Bombeck 27b054d8c4 fix(image): take scheme-less health-check URLs, anchor the compose pin, say which tools left 2026-11-04 14:32:58 +01:00
Marc-André Bombeck fb143a2d47 docs(release): screenshots for v1.47.0 2026-11-04 14:21:49 +01:00
Marc-André Bombeck bc4b9f4350 chore(release): v1.47.0 2026-11-04 14:13:49 +01:00
Marc-André Bombeck 3149805592 Merge branch 'fix/v147-debian-slim' into release/v1.47.0 2026-11-04 13:15:49 +01:00
Marc-André Bombeck 849fe4b2c6 build: stop tracing the musl canvas prebuilts the glibc image never loads 2026-11-04 13:15:49 +01:00
Marc-André Bombeck e547a77683 docs: describe the image as the official Node 22 Debian slim image
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck 09d932ce2a ci: fail the container scan only on findings with a fix
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck b6b693717c build: run the image on the official Node 22 Debian slim image
The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
2026-11-04 13:13:56 +01:00
Marc-André Bombeck 5dd6453773 fix(garmin): compare the cached token's key in memory, drop an unused export 2026-11-04 12:02:46 +01:00
Marc-André Bombeck de47cb7b31 test(analytics): list the Garmin and training types in the parity goldens 2026-11-04 11:15:03 +01:00
Marc-André Bombeck 8a89d5cfc3 Merge branch 'feat/v147-garmin' into release/v1.47.0 2026-11-04 10:58:59 +01:00
Marc-André Bombeck 2348073eeb docs(garmin): describe the service choice, key validity and skipped data
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 73f16e5389 docs(garmin): document the Personal API integration
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck 13e232c836 test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00
Marc-André Bombeck 67909f2d9d fix(db): derive the app role's password with scrypt
The key it comes from is one the operator chose, and a fast hash over a
weak one would be cheap to walk back from a leaked verifier.
2026-11-04 01:46:59 +01:00
Marc-André Bombeck f266555fe8 build: hold each message catalog to 250 KB gz, for the Russian one
Cyrillic is two bytes per letter in UTF-8, so the Russian catalog is
240 KB gz against under 210 for the Latin-script ones. The reason and the
numbers are in the budget file.
2026-11-04 01:46:58 +01:00
Marc-André Bombeck 4762ca43c2 docs(changelog): v1.47.0 entries 2026-11-04 00:56:27 +01:00
Marc-André Bombeck a162990d18 test(medications): drop the timeout the BYSETPOS property no longer needs 2026-11-04 00:45:53 +01:00
Marc-André Bombeck 65dc9751e0 test(medications): generate BYSETPOS rules that always have an occurrence
A rule with no occurrence (BYSETPOS=2 over one weekday) makes rrule walk
to its year 9999 on every call, seconds per case, which no schedule the
app accepts can produce. The reviewed counterexample is a fixed case.
Also formats the locale settings file.
2026-11-04 00:45:28 +01:00
Marc-André Bombeck 1bbd2d97c7 Merge branch 'fix/v147-screen-negations' into release/v1.47.0 2026-11-04 00:17:01 +01:00
Marc-André Bombeck 1a4703f4fe feat(insights): name the part of the day on the blood pressure stats while narrowed
The stat strip reads the chart's visible window, so with the chart
narrowed to the morning its figures were morning figures under a plain
Systolic label. The column label now says so. Russian texts for the
time-of-day card.
2026-11-04 00:17:00 +01:00
Marc-André Bombeck 62d1f57de8 fix(ai): block a negated take, a pause, an increment and a liquid dose
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").

Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.

The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
2026-11-04 00:14:56 +01:00
Marc-André Bombeck 22f83aed6e Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0 2026-11-04 00:13:45 +01:00
Marc-André Bombeck b93241aa87 feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.

The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.

`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.

Refs #1331
2026-11-04 00:12:49 +01:00
Marc-André Bombeck cac0668119 test: read the Dockerfile through readSource in the wget shim test
Plus six Russian strings smoothed in review.
2026-11-03 23:52:57 +01:00
Marc-André Bombeck b2fb295d43 Merge pull request #1322 from l1137l/feat/ru-locale 2026-11-03 23:50:35 +01:00
Marc-André Bombeck 0007973b78 fix(ai): tighten the ru dose bank
End a dose unit at a Cyrillic letter, so «3 месяца» no longer reads as
"3 МЕ". Keep ml out of the drug units as the other banks do, and stand
the target-dose shapes down on a dietary noun (water, sodium, caffeine),
which blocked plain nutrition advice. Cover the stop and inject verbs
(перестаньте, бросить, колите) the English bank already blocks.
2026-11-03 23:45:36 +01:00
Marc-André Bombeck 3e2e2ad56b Merge branch 'pr-1322' into review/pr-1322 2026-11-03 23:37:22 +01:00
Marc-André Bombeck 2d8b782f34 Merge branch 'fix/v147-review-dbrole' into release/v1.47.0 2026-11-03 23:35:54 +01:00
Marc-André Bombeck a80fcf5276 docs: name the per-database role and describe the owner-owned queue schema 2026-11-03 23:31:38 +01:00
Marc-André Bombeck a10bebdc43 fix(db): give each database its own app role and keep the queue schema the owner's
Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.

The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
2026-11-03 23:31:37 +01:00
Marc-André Bombeck 41e75b5bde Merge branch 'fix/v147-review-pdf' into release/v1.47.0 2026-11-03 23:30:26 +01:00
Marc-André Bombeck da48f1af9e Merge branch 'fix/v147-review-wolfi' into release/v1.47.0 2026-11-03 23:30:25 +01:00
Marc-André Bombeck 7af9ebe480 docs(stats): say what the r² clamp does and does not fix 2026-11-03 23:30:21 +01:00
Marc-André Bombeck 0e7134a2ea fix(labs): let a deleted number not hold a text marker's unit, and read the winner's unit after a race
Refs #1313
2026-11-03 23:30:21 +01:00
Marc-André Bombeck 55a3a9b53d fix(medications): keep the rrule anchor for a rule with BYSETPOS
The library drops a period's candidates before DTSTART before BYSETPOS
picks among them, so a start moved into the middle of a week chose a
different day. The property now generates BYSETPOS rules and kept red
without the guard.
2026-11-03 23:30:20 +01:00
Marc-André Bombeck 42cae738f1 build: pin the runner's Node and PostgreSQL client packages
nodejs-22 and postgresql-18-client are installed at exact package
versions, so a rebuild of the same commit runs the same Node and the
same pg_dump; apk upgrade still moves the libraries under them. No
updater reads an apk pin. The image scan does the job instead: Wolfi
records the fixed version for each advisory, so a pinned package with a
published fix turns the HIGH/CRITICAL gate red and names the version.
A guard holds the pin format and keeps the client's minor on the bundled
database's.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck a11ea8444d build: ship a wget for health checks written outside the image
The Wolfi runner has no wget, but health checks outside this repository
call it: compose files copied from here up to v1.46.6 run
'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS
catalog app renders 'wget --quiet --spider <url>'. Either replaces the
image's HEALTHCHECK, so such a container never turned healthy and a proxy
that routes on health dropped its traffic.

/usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider,
-q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are
ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection
or the timeout. It follows no redirect and refuses any other output file
before sending a request, so it is no download tool. The image's own
HEALTHCHECK and the bundled compose file keep the Node probe.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck 5705184857 test(documents): drop a mock of a module that no longer exists 2026-11-03 23:24:49 +01:00
Marc-André Bombeck 253176d01a fix(documents): index every page of a PDF read by its text layer
"Read with AI" and the index backfill wrote a text-layer read as a complete
`local-pdf` index although it held only the first ten pages (the render
cap), while the local text index reads up to 200. `prepareVisionInput` takes
the number of pages a text-layer read may take, and both index writers ask
for the local index's own cap, so the index is complete.

The `partial` flag on a text read had no reader and is gone; the
extraction and the lab scan report their missing pages themselves.

Refs #1311
2026-11-03 23:24:49 +01:00
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00
Marc-André Bombeck 119431dc15 fix(documents): fence document text in the summary and the filing suggestion
Both text modes spliced the text after a bare `OCR TEXT:` label, while the
extraction, the lab scan and the label read fence it. A PDF read by its
text layer on a server that cannot render now reaches these prompts, and
the automatic summary reads imported PDFs that way, so text the page does
not show (white on white) could write past its own end. The text is now
fenced with `fenceDocumentText`, the browser-OCR path included, and a marker
forged inside it is scrubbed.

Refs #1311
2026-11-03 23:24:26 +01:00
Marc-André Bombeck b975e86b39 test(clinician-share): start every case on an all-modules-on map
Two cases switch the doctor report off for the rest of their block, and
the blocks that did not set the map themselves inherited it under a
shuffled order, so a record share stopped aggregating.
2026-11-03 22:39:16 +01:00
Marc-André Bombeck 144840bab9 test(documents): type the fixtures the text-layer cases added instead of casting them
The stored bytes are already a Buffer and the MIME literal already fits
its union, so neither needs a cast; the external pick goes through one
helper. The as-never ratchet tightens accordingly.
2026-11-03 22:36:18 +01:00
Marc-André Bombeck 7cb67c2e75 Merge branch 'fix/v147-nightly' into release/v1.47.0 2026-11-03 22:30:50 +01:00
Marc-André Bombeck a598b00081 test(coach): start every snapshot case with no readings
The query-shape case read the measurement mock without setting it and
passed only after a case that left rows behind.
2026-11-03 22:25:43 +01:00
Marc-André Bombeck 1ac69420d5 test: reset to the factory answer instead of casting a new one
A reset mock goes back to the implementation its factory gave it, which
is the fresh session and the open capability these cases want; the cast
replacements grew the test-realism count.
2026-11-03 22:22:32 +01:00
Marc-André Bombeck 9a3140a4d3 test(backup): hold the record-shaped slices at their edges
The health score day, the intraday curve, a medication with its
composition and custom category, an intake row and the cycle's own
symptoms had fields and defaults no slice test met, added since the
floor was set; the backup-validation score had drifted to 40.2 % under
its 41 % floor. It is 54.1 % with these cases, and the floor follows
to 53 %.
2026-11-03 22:15:05 +01:00
Marc-André Bombeck fa439601c4 test(labs): give the unitless-marker refusal its own marker
The refusal case leaned on the marker the case before it mints, and
under a shuffled order it ran first and minted it itself.
2026-11-03 22:13:32 +01:00
Marc-André Bombeck b1cb387062 Merge branch 'fix/v147-pdf-unrenderable' into release/v1.47.0
# Conflicts:
#	.env.production.example
2026-11-03 22:10:14 +01:00
Marc-André Bombeck 3547c4bd2f test: pin the intake history's success badge at the one it now renders
The taken badge moved into one shared component, so the file carries the
token once.
2026-11-03 22:08:05 +01:00
Marc-André Bombeck 394828449a test(documents): read unrenderable PDFs through the routes against Postgres 2026-11-03 22:07:28 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck c47f61f02a feat(documents): read a PDF's text layer page by page without rendering
The reader child gains a `page-texts` request: the first pages' text
layers, each read the way a rendered page's is (cut and marked past the
per-page cap), plus the document's page count. Nothing is rendered, so it
works where the page renderer cannot run.

`readPdfTextLayer` judges what that read is worth: a page counts only with
at least 40 characters of its own besides white space, so a scanner's
stamped page number or fax header does not pass for a page of text; a PDF
with no such page is a scan and is reported as one.
2026-11-03 22:07:11 +01:00
Marc-André Bombeck d1ab195b43 Merge pull request #1330 from FloMaetschke/feature/injection-site-in-intake-history 2026-11-03 22:06:35 +01:00
Marc-André Bombeck a503c8855d Merge branch 'feat/v147-wolfi' into release/v1.47.0 2026-11-03 21:56:23 +01:00
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00
Marc-André Bombeck 2c33c2a0a0 test(integration): stop cases reading what another case left behind
- the AI-state cases take their baseline in a world of their own
  instead of from the S0 case;
- the live-window case drops a rollup tier the rollup case built;
- the job readers start each case on an empty queue, and the
  no-schema case starts pg-boss again after it;
- the server move case reseeds the server it emptied;
- the extremes cases rebuild the tier one of them breaks on purpose;
- the first Google Health backfill seeds its own history;
- the batch budget warms the process caches as the reads do;
- the HOT-update count is read as a difference: since Postgres 15 the
  transaction view also shows an earlier transaction's unflushed counts;
- the MANAGE matrix checks its effect set after all cases rather than
  in a case of its own that a shuffle could run first.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck d7046c9737 test(integration): keep the stateful walks in order, give the rest their own rows
The migration before-and-after files, the dump directory, the geo
backfill's three passes, the settings trip, the backup parity steps,
the lab conversion record and the pulse method's dry run before its
rebuild are each one sequence by design; they now say so and keep their
order with shuffle: false. The CSV import's zone and crash cases wrote
into the account the lab and medication walks count, and now use
accounts of their own.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck 713806fe9b test(parity): keep each route walk in order under a shuffled run
A parity file is one account walked through a route's cases, each
request reading what the earlier ones stored, and the recorded files
pin that sequence. The nightly shuffle reordered the steps and every
snapshot after the first moved. Keep each walk in order with
shuffle: false, wrapping the files whose top-level blocks build on each
other, and note the rule for order-bound files in the testing guide.
2026-11-03 21:35:56 +01:00
Marc-André Bombeck 899a054e64 Merge branch 'feat/v147-mcp-reading-id' into release/v1.47.0 2026-11-03 21:33:50 +01:00
Marc-André Bombeck 66709222b2 docs(api): describe the lab reading id next to the analyte id 2026-11-03 21:33:18 +01:00
Marc-André Bombeck 52e6bcd480 test(mcp): prove a report's lab link lands on that report's reading
Two draws of one analyte a year apart and two reports on the same day, against
Postgres: each report lists its own reading id, and fetching it returns that
reading with that report in its links, not the latest one.
2026-11-03 21:33:17 +01:00
Marc-André Bombeck ba27e728cf feat(mcp): fetch one lab reading by its own id
A link to a lab result named its analyte, and fetch lab:<analyte> answers
with the latest reading of that marker. Following a link from a report that
holds an earlier draw therefore landed on a later reading, whose links named
a different report and visit.

fetch labresult:<id> now returns that one reading (value or text result,
unit, reference range, status, date) with its own links, behind the labs
module and narrowed to the caller's live rows. Every link to a lab result
uses this id, each reading in get_labs carries it, and fetch lab:<analyte>
names the reading it quotes in metadata.readingId. The analyte view and
search are unchanged.

Refs #1323
2026-11-03 21:33:16 +01:00
Marc-André Bombeck 691c2e7301 refactor(labs): shape one reading from one shared select
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
2026-11-03 21:33:14 +01:00
Marc-André Bombeck cd344125d5 fix(labs): let a text-only marker take the unit of its first number
A marker minted from a text result ("SEE NOTE") has an empty unit, and a
marker with a reading has its unit locked, so the first number to arrive,
a unitless ratio included, was refused against a unit nothing had chosen.
While every reading is text the unit has nothing to contradict, so the
first number sets it. A marker that already holds a number keeps its unit.

Refs #1313
2026-11-03 21:19:28 +01:00
Marc-André Bombeck 377e0d0d3a build: copy pg_dump and pg_restore from the PostgreSQL image
postgresql-client-18 from the PGDG repository brought Perl and Debian's
client wrapper scripts into the runner for two binaries the app calls by
name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now
hands over pg_dump, pg_restore and the libpq they link; the Debian
libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the
upgrade and the image scan see them. The runner registers libpq with the
loader and fails the build when either binary has an unresolved library;
the major 18 version checks stay.

The client minor follows the bundled database's: a guard holds the two
pins together, and the Dockerfile's Dependabot watch now ignores a
PostgreSQL major the same way the compose watch does.
2026-11-03 21:18:44 +01:00
Marc-André Bombeck cf7905dc92 refactor(medications): hold recurrence.ts at its size baseline 2026-11-03 20:58:46 +01:00
Marc-André Bombeck 227c80878c test: give every case its own mock state under a shuffled order
Cases leaned on what an earlier case set up or left behind: a once-value
the code under test never consumed, an implementation swapped in and
kept by clearAllMocks, a payload only another block's setup provided,
the zone resolver's per-user cache, module state in a hoisted object,
and a scope set one case discovered for the next. Each now resets or
states what it reads.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 4f7802ba28 test(browser): leave no state behind for the next case
Await the form's explicit unmount: an unmount left running overlaps the
next render's act, and React then commits nothing for the rest of the
file. Return to the newest history entry after stepping back, so the
next case's push adds an entry instead of replacing the forward one.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 54d0647a6d fix(stats): keep r² within [0, 1] when y sits far from zero
The centred sums lose their last digits when every y is close to one
large value, and a two-point fit came out at r² 1.00000004. Clamp the
ratio to 1 and the y sum of squares at 0, and pin the two shrunk
counterexamples the nightly property run found.
2026-11-03 20:58:37 +01:00
Marc-André Bombeck 270c6ca619 refactor(medications): keep the rrule start alignment in its own module
recurrence.ts sits on the source-size ratchet; the helper and its reasoning
read on their own.
2026-11-03 20:57:46 +01:00
Marc-André Bombeck d8743e9111 chore: drop the trivyignore entries for npm's bundled packages
Both covered copies inside the npm CLI of the Node base image. The
runner removes npm, so neither CVE matches anything in the image; a scan
of the built image with an empty ignore file reports neither.
2026-11-03 20:53:29 +01:00
Marc-André Bombeck 3d0ae05192 docs: say the published image renders documents on a Raspberry Pi 4
The image runs the glibc canvas build now, which needs no ARMv8.2
features, so the Pi 4 gets thumbnails and scanned-PDF rendering back
without a code change: the gate only ever closed for musl on arm64. The
gate's comment, the env example and the getting-started prerequisites
say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays:
the glibc x64 build rendered a PDF page under an emulated core without
AVX2, but it still carries AVX2 code and that one run does not cover
every path.
2026-11-03 20:53:28 +01:00
Marc-André Bombeck de7858100f build: run the image on Debian trixie instead of Alpine
Every stage now starts from node:22-trixie-slim, pinned by its multi-arch
index digest, so the native modules load their glibc builds. The arm64
musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with
SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the
glibc build runs there (#1311).

The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the
nextjs user (uid 1001, now in group nodejs gid 1001), the backup and
GeoLite2 directories and every build-time assertion. pg_dump and
pg_restore of major 18 come from the PGDG apt repository, whose signing
key is fetched by node and checked against a pinned SHA-256 before apt
trusts it; PATH names the major 18 directory so the binary does not
depend on Debian's version-picking wrapper. The canvas hoist takes the
-gnu binary package by the pinned version and still fails the build on a
mismatch. bookworm-slim was the other candidate; on the day of the
switch it carried a CRITICAL zlib finding that trixie-slim does not.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 65e35761ec build: probe container health with Node's fetch instead of wget
The probe now needs nothing but the node binary the image already runs,
which frees the base image from carrying wget. The image HEALTHCHECK and
the compose healthcheck use the same one-liner: a non-2xx answer, a
refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout.
The boot-storm guard reads the abort from the fetch and refuses a probe
that names wget or curl.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 775933b7d5 Merge branch 'feat/v147-dbroles' into release/v1.47.0 2026-11-03 20:52:17 +01:00
Marc-André Bombeck 3d1a6224ba test: drop vi imports the removed schema-mirror mocks left unused 2026-11-03 20:49:31 +01:00
Marc-André Bombeck df1e19f1c2 docs: describe the app's database role, its limits and how to opt out 2026-11-03 20:38:46 +01:00
Marc-André Bombeck c7ff22ab00 chore(env): whitelist DB_APP_ROLE and document it beside the database settings 2026-11-03 20:37:48 +01:00
Marc-André Bombeck 485161a657 feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.

The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
2026-11-03 20:36:37 +01:00
Marc-André Bombeck 95525a0259 fix(jobs): let pg-boss create its schema only when it is missing
Postgres checks the database's CREATE privilege before it checks
whether the schema exists, so CREATE SCHEMA IF NOT EXISTS fails for a
role that may not create schemas even when the schema is there. The
worker now asks first and passes createSchema: false when it exists.
2026-11-03 20:32:53 +01:00
Marc-André Bombeck f05a112c61 feat(jobs): leave index drops to the owner when maintenance runs without it
DROP INDEX belongs to the table's owner; the MAINTAIN privilege covers
VACUUM and REINDEX but not the drops of retired indexes and invalid
rebuild copies. A run that does not own measurements now skips those
drops and names what is left, for the boot to drop as the owner. The
retired-index list moves to a plain module the boot script can read.
2026-11-03 20:32:53 +01:00
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00
Marc-André Bombeck 381726f471 perf(medications): start the rrule walk just before the window
between() iterates from DTSTART, so every compliance read walked a daily
schedule from the day the medication was created: about 4 ms per rate,
growing with the medication's age, and enough under a loaded test run to
push a compliance property past its timeout. DAILY and WEEKLY rules repeat
with a fixed period, so the start moves forward by whole periods to just
before the window. A property test holds the occurrences in the window
equal to the walk from the real anchor.
2026-11-03 20:23:31 +01:00
Florian Maetschke 8643a1ab8e Note the injection-site chip in the changelog 2026-11-03 12:03:33 +01:00
Florian Maetschke a3516160c0 Share the taken/skipped chip between the two history layouts
The desktop table and the mobile cards carried the same status badge
twice. Pulling it into one component makes room for the injection-site
chip without growing the list function past its size baseline, and
the baseline tightens to the new, lower count.
2026-11-03 11:49:41 +01:00
Florian Maetschke 5aac158b12 Show the injection site in the intake history
An injected dose already stores where it went, but the per-medication
history only showed when it was taken and where the entry came from.
The list now renders the site as a chip next to the status, on the
desktop table and the mobile cards, so the rotation can be read back
from the history. Rows without a site are unchanged.
2026-11-03 11:46:07 +01:00
dependabot[bot] 540f4ff84f build(deps): bump the production-weekly group across 1 directory with 3 updates (#1329)
Bumps the production-weekly group with 3 updates in the / directory: [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3), [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) and [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas).


Updates `@aws-sdk/client-s3` from 3.1159.0 to 3.1161.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1161.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1159.0 to 3.1161.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1161.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.13 to 1.1.0
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.13...v1.1.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1161.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1161.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 21:12:37 +00:00
l1137l c6372bde94 Merge main v1.46.6 into feat/ru-locale
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline.
- messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
2026-11-02 21:07:44 +00:00
Marc-André Bombeck 2c8140a502 Merge pull request #1328 from MBombeck/release/v1.46.6
release: v1.46.6
2026-11-02 21:39:10 +01:00
Marc-André Bombeck 1bde3fc618 chore: drop exports nothing imports
knip 6.44 follows the members a dynamic import reads, and the Insights
page reads two of the derived barrel's: the barrel now exports those two,
whose building blocks are imported from their own modules. BAND_VAR had
no reader, and the drawer re-exported useResettableValue for a test that
only imports nextResettableValue. The size and version-stamp baselines
take the lower numbers.
2026-11-02 21:22:16 +01:00
l1137l 48bfbad4e6 fix(i18n): address the ru review — units, Cyrillic regexes, locale registry
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
  "пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
  people actually see on the box; the deferral words ("спросите врача",
  "как назначено") stand those shapes down.

Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
  population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
  (?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
  thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
  edges around the written-date pattern.

Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
  and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
  stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
  no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.

Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
  version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
  «Процент жировой массы», which is what the two metrics measure.

Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
  view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
  dash is Russian punctuation, not a fragment join); the middle dot still
  fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.

pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
2026-11-02 20:22:03 +00:00
Marc-André Bombeck 94da0bc352 test: tighten the version-stamp baseline for the review row 2026-11-02 20:58:15 +01:00
Marc-André Bombeck 165120e845 chore(release): v1.46.6 2026-11-02 20:52:22 +01:00
Marc-André Bombeck 49a0eb92ff fix(labs): word a value without a unit the same on every remaining surface
The briefing's lab input passed the stored "1" to the model, the unit
mismatch refusal read "is in 1", and the trend card left a gap where a
language puts the unit before a particle. get_labs says that a value
without a unit has an empty unit, and a lab link id is scrubbed like its
label. The review row is back at its baseline length.
2026-11-02 20:52:18 +01:00
Marc-André Bombeck 6a8ac98613 fix(labs): let a unit typed key by key pass through "1"
The no-unit state was read from the value, so the first keystroke of
10^9/L or 1/min ticked the box and locked the field. Only the box makes
the choice now; a typed 1 stays text.
2026-11-02 20:52:17 +01:00
Marc-André Bombeck 70cc753c49 Merge branch 'fix/pdf-unicode-font' into release/v1.46.6 2026-11-02 20:38:27 +01:00
Marc-André Bombeck ee0adae99c test(doctor-report): read the font guard's files through readSource 2026-11-02 20:36:16 +01:00
Marc-André Bombeck 0115a02d22 fix(doctor-report): draw the PDF in an embedded Unicode font
Helvetica's WinAnsi encoding turned every glyph outside Latin-1 into an
empty box: a Polish report lost its diacritics, a Korean report was
unreadable, and free text in any other script broke in every locale.

The renderer now registers the embedded ReportSans family on each
document, names it in every setFont, and makes it the autoTable default.
The Hangul variant is read only when the report is Korean or its data
contains Hangul. Font files are cached per process; streams are
compressed so the subset fonts add about 30 KB to a report.

The arrows, superscripts and comparison signs now print as themselves,
so the WinAnsi sanitiser and its ASCII fallbacks are gone. Tests render
Polish, Russian and Korean strings and arrows and read them back out of
the text layer.
2026-11-02 20:32:17 +01:00
Marc-André Bombeck 80a62afbbf build(pdf): add Unicode fonts for the doctor report
The report's built-in Helvetica is WinAnsi-only. These fonts cover what
the report can carry: ReportSans (Noto Sans plus the arrows and
mathematical operators of Noto Sans Math; Latin incl. Extended-A/B,
Greek, Cyrillic) in regular, bold and italic, and ReportSansKR, the same
plus Hangul from Noto Sans KR, in regular and bold.

scripts/build-pdf-fonts.py rebuilds them from the upstream sources. All
are SIL OFL 1.1; the licence sits beside the files. The directory is
named in outputFileTracingIncludes because the renderer reads the files
by a path the tracer cannot follow.
2026-11-02 20:32:11 +01:00
Marc-André Bombeck a822aa5a0e fix(documents): send a read that states no unit itself to review
A unit of "1" is the explicit no-unit choice a person makes. When a
document read returns it on its own, the value is staged for review
instead of passing straight through, so a unit the read dropped never
lands as a dimensionless value.
2026-11-02 20:31:33 +01:00
Marc-André Bombeck 2c6fb82326 Merge branch 'feat/unitless' into release/v1.46.6 2026-11-02 20:30:41 +01:00
Marc-André Bombeck e605fe0ae8 docs(api): describe the dimensionless lab unit on every lab unit field
The unit on lab results, biomarkers, the scanned-report commit and the
document fact edit now states that "1" means the value has no unit, that
it matches only itself, and that an empty unit stays refused. The lab CSV
import description names the 1 in the unit column.

Refs #1313
2026-11-02 20:28:35 +01:00
Marc-André Bombeck f9431da64f feat(labs): show a value without a unit as the number alone
Every place a lab unit is printed beside a value now goes through one
helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the
list, history and detail, the chart and its tooltips, the reference bar
and range notes, the trend card, the timeline, the day view, the
clinician page, the doctor-report PDF and the changes card. Where the
unit stands alone (a marker's subtitle, a picker row, the merge preview)
it reads "No unit".

The Coach's lab block, the get_labs history and the visit-prep summary
hand the model an empty unit for such a value, so it never quotes the
"1" either.

Refs #1313
2026-11-02 20:28:34 +01:00
Marc-André Bombeck 5405ce053d feat(fhir): code a lab value without a unit as the UCUM unity
A value stored with unit "1" carries system and code on its quantity
even when the analyte map knows no coding for it: the unity is UCUM by
definition.

Refs #1313
2026-11-02 20:28:34 +01:00
Marc-André Bombeck 79ac5670c7 feat(labs): offer an explicit no-unit choice for ratios and indices
A ratio (cholesterol/HDL, albumin/globulin) or an index such as INR has
no unit, and every lab write path refuses a numeric value without one.
That refusal stays: an empty unit usually means the extraction dropped
it. Instead the value is stored as the UCUM unity "1", chosen on purpose.

The unit field when a marker is defined, on a scanned report's row and
in the document review edit now carries a "No unit" checkbox that sets
"1". The CSV import takes 1 in the unit column, as the template and the
import sheet say; an empty cell is still refused, and other spellings
such as "ratio" or "-" stay the literal unit they always were.

"1" compares as a unit of its own: it matches only itself, so it is
refused under a marker kept in mg/dL and the other way round.

Refs #1313
2026-11-02 20:28:23 +01:00
Marc-André Bombeck cae6edec64 Merge branch 'feat/mcp-links' into release/v1.46.6 2026-11-02 20:25:04 +01:00
Marc-André Bombeck 5f9c8461a3 test(mcp): pin record links against Postgres
Source system and id without the address, the Documents switch, the empty
answer, the cap, document links to labs and medications, row ids, and that
another account's document never appears, not even through a link row that
points across accounts.
2026-11-02 20:24:28 +01:00
Marc-André Bombeck 34302867aa feat(mcp): list links on lab and medication reads, ids on visit and condition rows
get_labs readings (the latest, earlier values in full, trajectory readings)
carry the fetch ids of what they link to, with linksTruncated; each linked
record is described once per answer in linkedRecords, because one report
usually holds a whole panel and would otherwise repeat on every reading.
fetch lab: and fetch med: list their links like the other record kinds, and
get_medication_schedule rows carry their id and links inline. get_visits and
get_illness_recovery rows carry visit: and condition: ids, so a row's links
are one fetch away. The labs and illness blocks gain an opt-in to carry row
ids; the Coach never asks for them.
2026-11-02 20:24:28 +01:00
Marc-André Bombeck ea7c6c860e feat(mcp): share one record-link builder and list labs and medications on a document
The links fetch lists beside a visit, condition, document or vaccination move
out of record-search into record-links, so the lab and medication reads can
list theirs through the same builder. A linked document imported from
Paperless-ngx or Papra now names its source system and its id there, never
the instance address. fetch document: also lists the lab results read from
it and the medications filed against it, each behind its own module.
2026-11-02 20:24:27 +01:00
Marc-André Bombeck a2ce11fec4 Merge pull request #1327 from johnnyrepo026/fix/review-edit-failed-rows
fix(documents): offer the edit control on review rows whose save was refused
2026-11-02 20:15:16 +01:00
Marc-André Bombeck 7dfc2cbbea test(series): follow the hour bucket into the route mock and the dated-fixture guard
The route test recognised the bucket statement by its old GROUP BY
column, and the new fall-back-night test reads from a fixed date, which
the dated-fixture guard now allowlists with its reason.
2026-11-02 20:14:18 +01:00
Marc-André Bombeck e7c625cab7 chore(deps): next 16.4.3 and the weekly dependency updates
Folds the open dependency updates into one change: next,
@next/bundle-analyzer and eslint-config-next 16.4.3, the production
group (aws-sdk, MCP SDK, node-apn, TanStack Query and Virtual, dotenv,
jose, lucide-react, nodemailer) and the development group (Stryker,
@types/node, knip, shadcn). The runtime prisma install in the Dockerfile
pins dotenv in step with the app.
2026-11-02 20:10:38 +01:00
Marc-André Bombeck 0810285061 fix(series): keep the repeated hour of a fall-back night apart
The hour grain of the dense series read truncated the local wall clock,
so the hour a fall-back night runs twice folded both instances into one
bucket: on that day a sensor stream showed one mean for two hours of
readings. An hour bucket now starts at the reading's instant less its
local minutes and seconds, which keeps the two hours apart and still
starts a half-hour zone's buckets on the half hour.

The new integration test pins a New York fall-back night and a
half-hour zone to fixed dates, so the case runs every day instead of
only when the rolling window of the existing tests covers one.
2026-11-02 20:06:15 +01:00
johnnyrepo026 e3aae483b0 fix(documents): offer the edit control on review rows whose save was refused
A confident lab value that Finish review refused (no unit, or a unit that disagrees with the marker) showed the reason but no way to act on it, so it could only be discarded. The Check control now also opens on a row with a commit failure, not only on a low-confidence one.

FactRow grows by one line (the data-slot the new test finds the control by), so its size baseline moves from 262 to 263.

Refs #1313
2026-11-02 12:21:37 -06:00
l1137l 6b9d222643 feat(i18n): add Russian (ru) locale
Full Russian catalog (11,154 keys, 1:1 with en, identical nesting) and every
locale wiring point the app declares, so ru is a first-class locale instead
of an English fallback:

- messages/ru.json, project.inlang/settings.json, i18n boot script
- i18n config / load-locale, format-locale (ru-RU), date-fns ru
- labs: parse-reference-range RANGE_PROSE for Russian lab sheets
  ("3,5 - 5,0", "от 3,5 до 5,0", "до 5,6", "не более 5,6")
- AI: citation-coverage normative stems, coach system prompt and insights
  reply footer (Russian prompts must override the hard-coded
  "Respond in English"), population-norm patterns
- safety: outbound-screen Russian imperative/dose/unit/causality bank
- mental health: PHQ-9, GAD-7, WHO-5 and SCI wording

verify-message-catalogs.mjs passes for all 10 locales.
2026-11-01 17:11:31 +00:00
Marc-André Bombeck 577e43004f Merge pull request #1312 from MBombeck/fix/pdf-reader-crash-report
fix(documents): keep PDF reading alive on CPUs the canvas build skips
2026-11-01 01:45:56 +01:00
Marc-André Bombeck 773b943bde fix(documents): keep PDF reading alive on CPUs the canvas build skips
The arm64 musl build of the native canvas uses LSE atomics and RCpc
loads throughout, so loading it on an ARMv8.0 core such as the
Raspberry Pi 4 ends the process with SIGILL. The CPU gate only knew
the x64 AVX2 case, and pdf.js loads the canvas as soon as it starts,
so on such a host every PDF read died in the child: no text layer, no
index, and the manual read reported a busy reader.

The gate now also closes on linux/arm64 under musl when the CPU lacks
the atomics or lrcpc feature. A closed gate travels to the PDF child,
which refuses to resolve the canvas module, so pdf.js carries on
without it and the text layer reads; rendering and thumbnails keep
their existing fail-soft path. A child that ends without an answer
now logs its exit code and signal.

Refs #1311
2026-11-01 01:24:37 +01:00
Marc-André Bombeck 9a6d9b6793 Merge pull request #1310 from MBombeck/fix/display-name-greeting
fix(ui): name the account by its display name
2026-11-01 01:14:41 +01:00
Marc-André Bombeck 1c4bcce6c7 fix(ui): name the account by its display name
The dashboard greeting, the Insights greeting, the top bar and the
sidebar read the username, so a display name set on the profile card
reached the reports but none of the surfaces that address the account.
They now read one helper that returns the display name when set and
falls back to the username otherwise.

Refs #1309
2026-11-01 00:56:24 +01:00
Marc-André Bombeck 022c6de0b5 Merge pull request #1307 from MBombeck/fix/rollup-coverage-history
Count a type as rollup-covered only when its buckets reach its oldest reading
2026-10-31 12:14:49 +01:00
Marc-André Bombeck defa43d46c test(parity): fold the whole fixture history so the walk keeps the rollup path
The fixtures built their rollups with the read refresh, which folds ninety
days. Under the stricter coverage a type folded only that far takes the
live path; the fixtures now fold the whole history first, as the boot
backfill does, so the walk still reads the rollup tier.

The analytics snapshots change only in what the old coverage hid: the
summaries count all 120 weight and blood-pressure readings instead of the
91 the folded window held, with the means and the all-time in-target share
that follow from them.
2026-10-31 11:54:28 +01:00
Marc-André Bombeck 2d1f3fef1e test(e2e): say what is known about the missed lab-reading focus
The comment named a cause that was never observed.
2026-10-31 11:25:41 +01:00
Marc-André Bombeck 3d5d5fc4ff fix(rollups): count a type as covered only when its buckets reach its oldest reading
The coverage probe called a type covered as soon as one DAY bucket existed.
A write hook folds the day it writes, so a type whose history was not yet
folded (an account before its backfill, or a read racing the detached
refresh) had one fresh bucket, took the bucket-composed path, and its
summary counted that one day: three weight readings read as one.

A type is now covered when its oldest DAY bucket starts no later than its
oldest live reading inside the fold window. The bucket holding a reading
starts at or before it in any time zone, so the comparison holds without
knowing the account's day boundaries. Both sides are one index descent per
type; anything not covered falls back to the live aggregate as before.
2026-10-31 11:25:04 +01:00
Marc-André Bombeck 779097933e Merge pull request #1306 from MBombeck/fix/google-health-account-id-heal
Read the Google Health account id back; refuse a WHOOP profile without one
2026-10-31 11:16:54 +01:00
Marc-André Bombeck c66ffa9914 test(e2e): focus the lab reading once the timeline has redrawn it
The chart redraws its point targets when the marker's readings arrive; a
target focused just before that is replaced and no tip opens.
2026-10-31 10:59:23 +01:00
Marc-André Bombeck beb7187c2b test(e2e): wait for the saved range before reloading the score chart
The range tab saves with a PUT; reloading straight after the click could
cancel it, and the reloaded chart opened on thirty days again.
2026-10-31 10:42:26 +01:00
Marc-André Bombeck 717eb1d1e1 test(rollups): wait for the warm-up refresh before writing the next row
The summaries read starts ensureUserRollupsFresh detached. When the third
row's DAY bucket landed before that refresh, WEIGHT counted as covered by
its one bucket and the read reported a count of 1; a CI shard hit exactly
that.
2026-10-31 10:22:27 +01:00
Marc-André Bombeck 087c8868cf test: type the off-spec WHOOP profile and drop a version-stamped comment
The realism ratchet counts as-never casts and the version-stamp ratchet
refuses comment lines that open with a release; both caught the previous
commits.
2026-10-31 10:00:39 +01:00
Marc-André Bombeck 369c063701 chore(deps): raise node-forge to 1.4.2 and drop its audit exception
Five high advisories against node-forge <= 1.4.0 (signature, certificate
chain and TLS record handling) joined the one the exception covered. The
copy arrives only through @parse/node-apn; 1.4.2 is what node-apn 8.1.1
pins, and with the floor raised the exception has nothing left to cover.
2026-10-31 09:37:33 +01:00
Marc-André Bombeck b42b0d5fee fix(whoop): refuse a profile without a user id instead of storing "undefined"
String(profile.user_id) turned a missing id into the literal "undefined",
so every such connection would have looked like one WHOOP account and the
unique account id would have refused the second record. The callback now
treats it like a failed exchange (reason=token), as the Polar callback
already does for a token body without x_user_id.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck 5306ba7614 fix(google-health): read the account id back for connections that lost it
Migration 0491 cleared the shared "profile" placeholder to null. Those
connections sync normally, but the one-account-per-record check cannot see
them, so the same Google account could still be connected to a second
record until the first one reconnected.

After a clean sync cycle, a connection without an id now reads it the way
the callback does (profile, then identity) and stores it, guarded on the id
still being null so a reconnect landing meanwhile wins. An id another
record already holds is not written, and a failed read leaves the id for
the next cycle without touching the cycle's verdict. The outcome is on the
wide event as googleHealth.accountId.heal.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck eb47f04a66 Merge pull request #1305 from MBombeck/release/v1.46.5
v1.46.5: Azure OpenAI reasoning deployments
2026-10-30 17:44:07 +01:00
Marc-André Bombeck 685cb2fa01 fix(ai): learn the max_tokens refusal from the field the request carried
A call that ran beside the one that learned the lesson read today's
selection instead and failed with the 400. The matcher now also wants the
refusal to name max_tokens, so a refusal of another field that echoes the
request does not switch the token field.

Refs #1304
2026-10-30 17:13:09 +01:00
Marc-André Bombeck 31ecb75901 test(backup): golden fixture for v1.46.5 2026-10-30 17:09:12 +01:00
Marc-André Bombeck 2676f2fce0 chore(release): v1.46.5 2026-10-30 17:09:01 +01:00
Marc-André Bombeck 21af344054 fix(ai): send max_completion_tokens to Azure OpenAI reasoning deployments
An OpenAI-compatible gateway entry pointed at an Azure OpenAI v1 endpoint
was treated as a generic gateway and sent max_tokens and temperature, which
a reasoning deployment refuses with a 400. An Azure v1 endpoint now takes
the api.openai.com contract for a reasoning model, and any gateway that
refuses max_tokens for a model is asked again with max_completion_tokens
and remembers it, which covers deployments named something of their own.

Refs #1304
2026-10-30 17:08:43 +01:00
Marc-André Bombeck 0462c23841 Merge pull request #1303 from MBombeck/release/v1.46.4
v1.46.4: Google Health connects on every profile of an instance
2026-10-30 13:51:10 +01:00
Marc-André Bombeck 29b5bc78a0 chore(release): v1.46.4 2026-10-30 13:18:16 +01:00
Marc-André Bombeck 0d8bcbc52b test(backup): keep the 1.46.4 backup file as a golden 2026-10-30 13:18:15 +01:00
Marc-André Bombeck cd6cbc06f5 fix(google-health): store the account's id, not the word "profile"
The profile's resource name is `users/{user}/profile`, and the connect
stored its last segment, the same constant for every account. Since the
one-account-per-record check, a second record on an instance could not
connect or reconnect Google Health: it was refused as `already_linked`.

The connect now stores the `{user}` segment, reads `users/me/identity`
when the profile answers through the `me` alias, and stores the id as
unknown when neither names the account. A migration makes the column
nullable and clears the placeholder, so the next connect stores the
real id.
2026-10-30 13:17:54 +01:00
1284 changed files with 62459 additions and 9471 deletions

No files matched your search

+7
View File
@@ -38,6 +38,13 @@
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# Left commented here so that append does not create a duplicate key. Uncomment
# and set it directly instead if you prefer to edit the file by hand.
# The container runs the app as `healthlog_app_<database>_<hash>`, a database
# role it creates on every start that reads and writes rows but cannot change
# the schema. The
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
# you manage database roles yourself (docs/self-hosting/database-role.md).
# DB_APP_ROLE="off"
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
+17 -5
View File
@@ -526,6 +526,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
#
# The app runs as `healthlog_app_<database>_<hash>`, a database role the
# container creates for this database on every start: it reads and writes
# rows but cannot alter or drop tables, own anything, or create roles. Migrations and restores still use the user in DATABASE_URL.
# Where that user may not create roles (a managed database), the start log
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
# the database roles yourself. See docs/self-hosting/database-role.md.
# DB_APP_ROLE="off"
# -----------------------------------------------------------------------------
@@ -706,12 +714,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
# only). Override only if auto-detection misfires:
# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips
# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features
# degrade cleanly: no thumbnails, and an AI provider that cannot take PDF files
# reads a PDF by its text layer, so a scanned PDF needs a provider that takes
# PDFs directly (Anthropic) or photos of its pages. On arm64 the published image
# renders on every 64-bit core, the Raspberry Pi 4 included. Only a musl
# (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and is gated the
# same way. Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
# on — force-enable (crashes PDF processing if the CPU truly lacks them)
# NATIVE_CANVAS="off"
+15
View File
@@ -64,6 +64,21 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
# The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of
# the bundled database's major. A major bump there is part of the same
# migration decision as the database itself, and the runner's paths and
# version checks name major 18, so it arrives by hand, not as a PR. Its
# minor moves with the database's in docker-compose.yml
# (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
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: scorecard-results.sarif
category: scorecard
+19 -8
View File
@@ -245,12 +245,23 @@ jobs:
rm trivy-install.sh
trivy --version
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
# image fails the job. For a finding without an upstream fix
# (e.g. a base-image CVE Alpine has not patched yet), add the CVE
# id to a `.trivyignore` file at the repository root — Trivy reads
# it automatically — one CVE per line, each with a comment naming
# the tracking issue and the re-check condition. Never suppress a
# finding that has a fixed version available; bump instead.
# Every HIGH/CRITICAL finding in the built image, fixed or not, as a
# table in the job log. Reporting only: a Debian base carries
# advisories the distribution has not fixed yet (or marked as not
# worth a fix), and nothing this repository changes can remove those.
# The list is here so they stay visible and move to the gate below
# the day a fix is published.
- name: Trivy container scan (all findings)
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 0 healthlog:scan
# Blocking since v1.15.20 — a HIGH/CRITICAL finding with a published
# fix fails the job. `--ignore-unfixed` leaves out only the findings
# listed above that have no fixed version yet; a fix that reaches the
# Debian archive, the Node image or a dependency turns this red until
# it is taken (rebuild, bump the base digest, or bump the package).
# `.trivyignore` at the repository root (Trivy reads it automatically)
# is for a finding with a fix that cannot reach this image, one CVE
# per line with a comment naming what it covers and when to remove
# it. Never suppress a finding whose fix applies here; take it.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
run: trivy image --format table --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 healthlog:scan
+13 -3
View File
@@ -6,7 +6,7 @@ name: Trivy Scheduled Scan
# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every
# push to `main` and once a day, blocking on HIGH/CRITICAL.
# - `security.yml` → `docker-security` builds the image on a push to `main`
# and runs `trivy image` against it, blocking on HIGH/CRITICAL.
# and runs `trivy image` against it, blocking on HIGH/CRITICAL with a fix.
#
# Both are change-triggered, and both answer "is this change safe". Neither
# answers "is what we shipped last week still safe", because an advisory
@@ -88,7 +88,7 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
@@ -142,10 +142,17 @@ 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
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
@@ -162,3 +169,6 @@ jobs:
format: table
severity: "CRITICAL"
exit-code: "1"
# 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
-19
View File
@@ -3,25 +3,6 @@
# can apply; never suppress one with a fixed version available — bump instead.
# Each entry names what it covers and the condition to re-check / remove it.
# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's
# OWN bundled copy that ships inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at
# image-build time by npm, not by our runtime. Our application dependency
# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is
# not affected and the path is not reachable at runtime. Re-check when the
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
# then drop this entry.
CVE-2026-33671
# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only
# in the npm CLI's OWN bundled copy inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses
# sigstore for publish provenance at image-build time only; it is not in our
# application dependency tree at all (`pnpm why sigstore` is empty) and is
# never reachable at runtime. Re-check when the Node base image ships an npm
# build whose bundled sigstore is >= 4.1.1, then drop this entry.
CVE-2026-48815
# CVE-2026-85393 (node-forge RSA PKCS#1 v1.5 signature forgery, HIGH; GitHub
# advisory GHSA-86w9-cpqp-85rv) — node-forge <= 1.4.0 arrives only through
# @parse/node-apn, which uses it in its certificate-based credential path
+403
View File
@@ -1,5 +1,408 @@
# Changelog
## [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:<id>` 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
- Next.js moves to 16.4.5, which fixes seven moderate advisories. Most
concern `use cache` and Cache Components, which HealthLog does not use;
one concerns self-hosted apps that pass a fetched response on to the
client, and one the Edge runtime's internal headers.
- `@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
- **Blood pressure by time of day.** The blood pressure page has a card
with the morning, midday and evening averages side by side, each with
its count and pulse, and the morning minus evening difference once both
have at least three readings. Morning runs 04:00 to 11:59, midday 12:00
to 17:59 and evening 18:00 to 03:59 in your time zone, wide on purpose,
because home measurement guidance ties the two readings to waking and to
bedtime rather than to a clock. A control under it narrows the chart to
one part of the day, kept in the URL, and the stat strip names the part
while it is narrowed. The server computes the split, so the app can show
the same numbers (`GET /api/measurements/blood-pressure/time-of-day`,
and `partOfDay` on the daily series). (#1331)
- **Russian.** HealthLog speaks Russian, its tenth language: the whole
interface, the AI output language, the safety screen's Russian bank,
Russian month names in the coach's fact check, and the questionnaires
with the "show original" switch, marked as not yet validated. (#1322)
- **A lab reading has its own id over MCP.** `fetch labresult:<id>`
returns one reading with the report and visits it came from, and the
links on documents and visits now point at the reading that report
holds instead of the analyte's latest one. `lab:<analyte>` still answers
with the latest reading. (#1323)
- **The intake history shows where an injection went.** A dose logged
with an injection site already stored it, but the per-medication
history only showed when it was taken and where the entry came from.
Each injected dose now carries a chip with the site next to its status,
in the table and on the mobile cards, so the rotation can be read back
from the history. Doses without a site look as before.
- **Garmin, directly.** Garmin Connect now offers a Personal API, and
HealthLog reads it: create a key in Garmin Connect (Settings, Personal
API), paste it into the new Garmin card, and steps, distance, floors,
active calories, resting heart rate, sleep stages and sleep score,
overnight SpO₂, breathing rate and HRV, weigh-ins with body composition,
activities and logged period days sync every hour, with the whole
history filled in behind. Body Battery and Garmin's all-day stress level
arrive as types of their own (`BODY_BATTERY`, `STRESS_LEVEL`), never mixed
into the computed scores. Garmin keys expire after 30 to 365 days; the
card warns a week ahead and asks for a new key when Garmin stops
accepting the old one. Accounts on Garmin's China service work too; the
key goes only to the service you pick. The key is stored encrypted and
only ever sent to Garmin. (#1333)
- **Training Readiness and acute training load.** The Personal API does
not carry them, so they are new types (`TRAINING_READINESS`,
`ACUTE_TRAINING_LOAD`) a Home Assistant bridge can push through an ingest
token, and they chart on the recovery page next to Body Battery. The Home
Assistant guide has the automation. (#1333)
### Changed
- **The image runs on the official Node 22 image (Debian slim) instead of
Alpine.** The runtime is glibc now, so the native page renderer,
password hashing and the Prisma engine run their glibc builds. The
published arm64 image renders PDF pages on a Raspberry Pi 4 again, where
the Alpine build of the renderer needs CPU features an ARMv8.0 core
lacks. Node security releases now come with the official Node image,
which HealthLog pins and moves forward with its releases. The image
carries no package manager: npm, npx, corepack and yarn are removed.
`pg_dump` and `pg_restore` are PostgreSQL 18.6, the bundled database's
version. A few tools the Alpine image happened to carry are gone too:
`psql`, `pg_isready`, `pg_dumpall`, `ps`, `top`, `nc`, `unzip` and the
full `wget`. HealthLog uses none of them in the app container; run
`psql` in the `db` container. A health check that
still calls `wget`, from a compose file copied before this release or a
platform template, keeps working: the image carries a small `wget` that
answers health checks and nothing else. The current `docker-compose.yml`
probes with Node directly, and that is the one to switch to. (#1311)
- **The app connects to the database with less power.** On Postgres 17 or
later, with a database user that may create roles (the bundled compose
file's user may), the app and the worker now connect as a role of their
own that reads and writes rows but cannot change or drop tables, create
functions, read server files or create roles. Migrations, the dump
before them and restores still run as the owner, and so does the queue
schema, which the role may only write rows into. Nothing needs setting:
the role is created at start, with a password derived from an existing
secret, one per database. Where the role cannot be set up (Postgres 16,
a managed database without the right, `DB_APP_ROLE=off`), the app runs
as before and says why at start. `docs/self-hosting/database-role.md`
has the details.
### Fixed
- **Medication adherence reads faster.** Every rate walked a schedule from
the day the medication was created, so it slowed down as a medication
aged, about 4 ms per rate after two years. It now starts just before the
window it reads, with the same result.
- **A PDF the server cannot render is read by its text.** On a host
without the page renderer (an x86 CPU without AVX2), an AI provider that
takes pages as images could not read a PDF at all, and the message asked
for a Claude provider without saying why. A PDF with a text layer is now
read as text there, and a scan says plainly that this server cannot turn
its pages into images. A PDF that fails to render on a capable server is
reported as that, not as a provider problem. (#1311)
- **A marker that started with a text result takes the unit of its first
number.** A marker created from "see note" had an empty unit that locked
once it held a reading, so the first number, a unitless ratio included,
was refused. While a marker holds only text readings, its first number
now sets the unit. (#1313)
- **The AI safety screen catches more medication instructions.** A
negated take ("don't take your metoprolol tonight"), a pause, an extra
amount without a target ("add 4 more units of insulin") and a liquid
dose of a syrup or solution are now held back in all ten languages,
while advice such as drinking water or taking a break from screens
passes. A Dutch reminder not to stop a medication without a doctor was
held back before and passes now.
- **Least-squares fits keep r² within 0 to 1.** Values far from zero with
little spread could give an r² a hair above 1.
### Thanks
- @FloMaetschke for the injection site in the intake history (#1330).
- @l1137l for the Russian translation, the Russian safety bank and the
patience through two reviews (#1322).
- @johnnyrepo026 for finding the text-first marker that refused its first
number (#1313).
- @mbreitkreuz for testing the MCP links down to two reports on one day
(#1323).
- @Oguz3003 for staying with the Raspberry Pi 4 until it reads PDFs
(#1311).
- @mills1975 for asking how blood pressure differs over the day (#1331).
- @kruemelro for asking for a direct Garmin connection on Garmin's new
Personal API (#1333).
## [1.46.6] — 2026-11-02
### Added
- **Lab values without a unit.** Ratios and indices such as INR,
cholesterol/HDL or albumin/globulin had nowhere to go: manual entry, the
CSV import and the document review all required a unit. Entry, the
scanned-report rows and the document review now offer an explicit "No
unit" choice, stored as the UCUM unity `"1"`, and the CSV import reads
`1` in the unit column the same way. An empty unit is still refused, so
a unit the reading lost is not mistaken for none, and a document read
that answers "no unit" by itself is held for review. Such a value shows
as the number alone, a marker kept in a real unit refuses it, and the
FHIR export codes it as UCUM `1`. (#1313)
- **MCP reads follow their links.** Lab readings in `get_labs` and
`fetch lab:` list the documents and visits they are linked to, with the
linked records once per answer instead of on every reading of the same
report. `fetch med:` and `get_medication_schedule` list theirs, and
`fetch document:` now also names the lab results and medications filed
against it. Rows of `get_visits`, `get_medication_schedule` and
`get_illness_recovery` carry an id `fetch` takes. An imported document
names its source system and id, never the address of the instance it
came from. (#1323)
### Fixed
- **A refused lab value can be corrected in the review.** A value that
Finish review refused, such as a ratio printed without a unit or a unit
that disagrees with the marker, showed the reason but could only be
discarded. The row now offers the same Check control as a low-confidence
value. (#1313, #1327)
- **The doctor report prints every language.** The PDF used a built-in
font that covers Western European letters only, so Polish letters such
as ą, ł or ś, the whole Korean report, and any medication name or note in
another script printed as empty boxes. The report now embeds an open
Unicode font family covering Latin, Greek, Cyrillic and, when the report
needs it, Korean, and arrows, ≤ and ² print as themselves instead of
ASCII stand-ins. The file is compressed, so a report stays around
65 KB. Chinese, Japanese and Arabic text are not covered yet.
- **PDFs read on a Raspberry Pi 4.** The native canvas build for arm64
Alpine needs CPU features an ARMv8.0 core lacks, and loading it ended
every PDF read with an illegal instruction. The CPU check now covers that
case, and PDF reading carries on without the canvas, so the text layer
and the index work; thumbnails keep their existing fallback. (#1311)
- **The display name greets you.** The dashboard and Insights greetings,
the top bar and the sidebar showed the username even when a display name
was set. They now use the display name and fall back to the username.
(#1309)
- **Summaries count every reading right after an import.** A type whose
history was not folded yet counted as covered as soon as one day was, so
a summary could show one day of weight readings out of three. A type now
counts as covered only when its folded days reach back to its oldest
reading.
- **The repeated hour of a clock change stays two hours.** On the night
the clocks go back, a dense pulse or glucose chart folded both runs of
the repeated hour into one point. Each hour is its own point now.
- **Google Health heals a connection that lost its account id.** The
1.46.4 update cleared a placeholder id on existing connections. After a
clean sync such a connection now reads its real id back, so the
one-account-per-profile check covers it again.
- **WHOOP refuses a sign-in without a user id** instead of storing the
text "undefined" as the account.
### Changed
- Next.js 16.4.3 and the weekly dependency updates. node-forge, reached
through the APNs client, is raised to 1.4.2, which closes five high
advisories.
### Thanks
- @johnnyrepo026 for the precise report on unitless lab ratios (#1313),
with the cause already traced to the right two files, and for the pull
request that fixed the review half of it (#1327).
- @mbreitkreuz for the MCP linked-records request (#1323), a careful spec
with the real case that made date matching fail.
- @Oguz3003 for tracking the Raspberry Pi 4 crash down to the CPU and
confirming the workaround (#1311).
- @angusmaul for noticing the greeting ignored the display name (#1309).
- @mills1975 for the heart-rate sync report and the diagnostics that
pinned it to the iPhone app (#1326); the fix follows in an app update.
## [1.46.5] — 2026-10-30
### Fixed
- **Azure OpenAI reasoning deployments answer through the gateway.** An
OpenAI-compatible gateway pointed at an Azure OpenAI v1 endpoint sent
`max_tokens` and `temperature`, and a reasoning deployment (gpt-5 on,
the o-series) refused the request with a 400. A deployment named after
its model now gets `max_completion_tokens` and no sampling controls
from the first request, as on `api.openai.com`. A deployment with a
name of its own, or any other gateway that refuses `max_tokens` the
same way, is asked once more with `max_completion_tokens` and keeps
that for the endpoint and model. GPT-4 deployments and other gateways
keep the wire they had. Reported in #1304.
## [1.46.4] — 2026-10-30
### Fixed
- **Google Health connects on every profile of an instance again.** The
connect stored the same placeholder as the Google account's id for every
account, so since 1.44.0 a second profile on the same instance was
turned away with "This Google account is already connected to another
HealthLog profile", also when it only reconnected. The connect now
stores the account's real id. An update clears the stored placeholder,
and a profile that was turned away connects with its next attempt.
Instances with one Google Health connection were not affected.
## [1.46.3] — 2026-10-29
### Fixed
+208 -74
View File
@@ -1,5 +1,23 @@
# ── Base images ────────────────────────────────────────────
# glibc, not musl, in every stage. The native modules the runtime loads (the
# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine)
# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE
# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the
# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those
# instructions (#1311).
#
# Every Node stage, the runner included, is the official node:22-trixie-slim
# image, pinned by its multi-arch index digest. Node security releases reach
# that image within days of their publication, which no distribution package
# of an older Node stream matched. The runner removes the package managers
# the image ships; the one npm install the runtime needs happens in the
# `prisma-cli` stage and is copied over. pg_dump and pg_restore come from the
# official PostgreSQL image in the `pgclient` stage. The runner still checks
# the Node module ABI and the glibc against the builder at build time, so a
# future change to one of the stages cannot pair them wrongly.
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
@@ -11,7 +29,7 @@ COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
@@ -57,6 +75,10 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# The Node module ABI and the glibc the native packages were installed for;
# the runner refuses to build on an older glibc or another module ABI.
RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \
> /app/.native-abi
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
@@ -66,24 +88,143 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version"
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# ── Stage 3: Migration CLI ─────────────────────────────────
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. This is the
# only npm install the runtime needs; it runs here, on the runner's own base,
# and the runner copies `/opt/prisma-cli` over, so npm never reaches the
# runtime image.
#
# The `allowScripts` field is for npm 12 and later, which runs no dependency
# install scripts unless package.json lists the package there. Three packages
# here have one: `@prisma/engines` downloads the schema engine `migrate
# deploy` runs, `prisma` checks its environment, and `esbuild` (under tsx)
# checks its binary. The npm in this image still runs them without the field;
# keeping it means a later npm major cannot drop the schema engine quietly.
# Either way the runner asks for the engine file after the copy.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS prisma-cli
RUN mkdir -p /opt/prisma-cli && \
cd /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=^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
# ── Stage 4: PostgreSQL client ─────────────────────────────
# pg_dump and pg_restore of the bundled database's major, taken from the
# official PostgreSQL image (Debian trixie, the runner's own release; its
# binaries are the PGDG builds) together with the libpq they link. Debian
# trixie itself ships major 17. The PGDG apt package was the other route: it
# brings Perl and Debian's version-picking wrapper scripts for two binaries
# the app calls by name, and its repository drops a minor once the next one
# is out, so a pinned package version stops installing. A digest-pinned
# image stays buildable, and Dependabot proposes its next minor the way it
# does for the bundled database. The libpq file is found through the loader
# rather than by an architecture path, so the step is the same on amd64 and
# arm64.
FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient
RUN set -eu; \
mkdir -p /out/bin /out/lib; \
cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \
LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \
[ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \
cp -L "$LIBPQ" /out/lib/libpq.so.5
# ── Stage 5: Production runner ─────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner
# The package managers the base ships (npm, npx, corepack, yarn) go first.
# Nothing at runtime installs a package, and the bundled npm carried the only
# HIGH findings with a fix the image scan reported. Removed in a later layer,
# their files stay in the base layer's bytes but leave the image's file
# system, which is what runs and what the scan reads.
#
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC on Alpine images that ship without it.
# falling back to UTC; the slim base ships without it. tzdata stays unpinned
# on purpose: a zone rule change is a fix to take.
#
# `apk upgrade` pulls in OS-package security fixes the pinned base digest
# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest
# has not absorbed yet (the digest pin stays for build reproducibility;
# security patches from the Alpine repo override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan
# caught between base-image rebuilds).
# security patches from the Debian archive override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan caught
# between base-image rebuilds).
#
# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly
# database dump, the pre-migration dump in the entrypoint and the
# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the
# bundled database; a client dumps servers of its own major and older, so it
# also serves an install still on its own Postgres 16.
RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache
# `pg_dump` and `pg_restore` serve the nightly database dump, the
# pre-migration dump in the entrypoint and the `db-restore` service
# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a
# client dumps servers of its own major and older, so it also serves an
# install still on its own Postgres 16. The two binaries and their libpq come
# from the `pgclient` stage above; the Debian libraries libpq links
# (Kerberos, LDAP, SASL) come from apt below, so the build-time upgrade and
# the image scan see them. The copied binaries are outside dpkg: the scan
# does not see them and `apt-get upgrade` does not patch them, so a
# PostgreSQL client fix arrives by moving the `pgclient` pin.
# `src/__tests__/runtime-package-pins.test.ts` holds that pin to the bundled
# database's minor in docker-compose.yml.
#
# `ca-certificates` is the system trust store the OpenSSL-linked tools use
# (the Prisma schema engine, libpq) for a TLS connection to the database.
# `tar` and `gzip` for the GeoLite2 extraction and `sh` (dash) for the
# entrypoint are part of the Debian base.
RUN set -eux; \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v*; \
sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get upgrade -y --no-install-recommends; \
apt-get install -y --no-install-recommends \
ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /root/.npm /root/.cache
COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/
COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5
RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \
ldconfig && \
! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found"
ENV PATH=/usr/lib/postgresql/18/bin:$PATH
# The builder and the runner start from the same image, so the two agree
# today. The check stays: it fails the build the day one of them moves to
# another base or another Node major without the other.
COPY --from=builder /app/.native-abi /tmp/.native-abi
RUN set -e; \
read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \
rm -f /tmp/.native-abi; \
RUN_ABI="$(node -p process.versions.modules)"; \
RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \
[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \
[ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \
{ echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \
echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC"
WORKDIR /app
ENV NODE_ENV=production
@@ -114,8 +255,9 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \
--shell /usr/sbin/nologin nextjs
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
@@ -163,8 +305,8 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# HOISTED to the top level there (verified: without it pdfjs logs "Cannot load
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# the canvas package and the glibc (`-gnu`) binary package to
# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
# before the platform package, so it decides which binary runs.
#
@@ -181,24 +323,24 @@ RUN set -e; \
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
rm -f /tmp/.canvas-version; \
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \
GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
exit 1; \
fi; \
NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \
NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \
cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \
BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)"
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)"
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
@@ -250,13 +392,16 @@ COPY --from=builder /app/src/generated ./src/generated
# pending migration count, the second runs `migrate deploy` with its
# lock-timeout retry. `db-dump.mjs` takes the dump before migrating, and
# `db-restore.mjs` is the `db-restore` service; both share
# `src/lib/database-backup/dump-core.mjs` with the app. Root-owned on purpose:
# the app user can run them but not rewrite them.
# `src/lib/database-backup/dump-core.mjs` with the app. The files are
# root-owned, so the app user can run them but not edit them in place. The
# directory around them stays the app user's.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs
COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
@@ -276,48 +421,20 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
RUN mkdir -p /opt/prisma-cli && \
cd /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.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
# The migration CLI from the `prisma-cli` stage. The Prisma config loads
# dotenv from /app, so expose the isolated copy there. /opt/prisma-cli is
# root-owned: the app user can run it but not change it.
COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli
RUN ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma
# The schema engine the entrypoint's `migrate deploy` runs is in place.
# Look for the file before asking the CLI: a CLI that finds none downloads
# one, which would hide the missing install script here and fail offline.
RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \
node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \
grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at "
# Same proof for the intake-repair script under the launcher it is documented
# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
@@ -340,17 +457,31 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 |
# user so the runtime worker can WRITE it: a self-hoster who sets
# MAXMIND_LICENSE_KEY on the published image has the databases fetched into
# this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or
# manual mount. `tar`/`gzip` for the extraction ship in the busybox base. A
# manual mount. `tar`/`gzip` for the extraction come with the base. A
# read-only bind mount at this path (the bring-your-own-database route) still
# works — the runtime fetch just fails soft and the mounted files stand.
RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2
COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/
# `wget` for health checks written outside the image. A compose file copied
# from this repository up to v1.46.6 and the TrueNAS catalog app both call
# `wget --spider` against /api/health, and their check replaces the
# HEALTHCHECK below; the slim base has no wget, so without this every such
# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags
# those checks use, exits like wget, and writes nowhere but stdout. The
# image's own HEALTHCHECK and the bundled compose file use the Node probe.
# Root-owned: the app user can run it but not replace it.
COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs
RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n' > /usr/local/bin/wget && \
chmod 755 /usr/local/bin/wget && \
wget 2>&1 | grep -q "health check shim" && \
! wget --no-verbose --tries=1 --spider http://127.0.0.1:9/api/health
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
# Declared this late in the stage on purpose: the value differs on
# every CI run, and every instruction after an ARG shares its cache
# fate. Down here the only layers it invalidates are the cheap
# entrypoint COPY/chmod below; the npm-install layers above stay
# entrypoint COPY/chmod below; the layers above stay
# cache-warm across rebuilds of the same release.
ARG NEXT_PUBLIC_APP_BUILT_AT
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
@@ -369,9 +500,12 @@ ENV HOSTNAME="0.0.0.0"
# migration waiting out a held lock retries for up to about eight minutes.
# Five retries: a pool saturated for a minute during a boot storm makes the
# probe's 2 s database check miss once or twice without marking the container
# unhealthy (docker-compose.yml explains the numbers).
# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own
# fetch: it needs nothing beyond the runtime, the same probe works on any
# base, and no HTTP client has to be installed for one line. A non-2xx
# answer, a refused connection and the 4 s abort all exit 1.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \
CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
+1 -1
View File
@@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Docker image on the official Node 22 Debian slim base. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file. v1.47.0 raised maxCatalogKbGz from 210 to 250 for the Russian catalog: Cyrillic is two bytes per letter in UTF-8, so the same key set is 1.12 MB raw against about 0.8 MB for the Latin-script locales, 240 KB gz as an emitted chunk where the largest Latin one is under 210. A reader downloads one catalog, once; nothing else in the budget moved.",
"routeDriftAllowanceKbGz": 20,
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210,
"maxCatalogKbGz": 250,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
+11 -3
View File
@@ -81,6 +81,12 @@ x-healthlog-env: &healthlog_env
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
# The app connects as `healthlog_app_<database>_<hash>`, a role the
# entrypoint creates for this database that reads and writes rows but
# cannot change the schema; the migrations and the queue's install keep
# the owner above. off keeps the app on the owner (for an operator who
# manages database roles). See docs/self-hosting/database-role.md.
DB_APP_ROLE: "${DB_APP_ROLE:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
@@ -557,8 +563,10 @@ services:
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))",
]
interval: 30s
timeout: 5s
@@ -567,7 +575,7 @@ services:
# past that, so a boot storm that saturates the pool for a minute after
# the start period (backfills, a restore, another tenant's import) costs
# a probe or two, not the container's health and the proxy's routing.
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
# The probe's own 4 s abort ends the request inside Docker's 5 s, so a
# stalled connection reads as one clean miss rather than a killed probe.
# A database that stays away for 2.5 minutes is a real outage and shows.
retries: 5
+16
View File
@@ -107,6 +107,22 @@ NODE_PATH=/opt/prisma-cli/node_modules \
node scripts/migrate-deploy.mjs
echo "HealthLog: Migrations complete."
# ── The app's database role ──────────────────────────────────
# Everything above ran as the owner DATABASE_URL names. The app itself
# connects as `healthlog_app_<database>_<hash>`, this database's own role,
# which reads and writes rows and runs the queue but cannot change the schema (scripts/db-app-role.mjs says what it may and
# may not do, and docs/self-hosting/database-role.md how to opt out). The
# script hands the role's URL over on descriptor 3 only, never on stdout or
# stderr; nothing handed over means it said why and the app stays on the
# owner, as before. The owner's URL is replaced, not kept beside it, so it
# does not reach the app's environment.
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
if [ -n "$app_database_url" ]; then
DATABASE_URL="$app_database_url"
export DATABASE_URL
fi
unset app_database_url
# ── Start application ────────────────────────────────────────
echo "HealthLog: Starting application..."
exec "$@"
+3 -1
View File
@@ -45,12 +45,14 @@ 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
<https://docs.healthlog.dev>.
- [`integrations/`](./integrations/) — per-integration reference:
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
the Garmin route; the Apple Health, Health Connect, document and data
Garmin; the Apple Health, Health Connect, document and data
imports; AI providers; and Home Assistant with its blueprint. Oura and
Polar are covered on the docs site only.
- [`adr/`](./adr/) — architecture decision records.
+8
View File
@@ -110,6 +110,14 @@ What `search` matches for the clinical records:
reading deferred is matched on its title and details only.
- `vaccination:<id>`: vaccine name, catalogue name, practitioner.
A lab has two ids. `lab:<analyte>` is the analyte view and answers with that
marker's latest reading. `labresult:<id>` is one reading: its value or text
result, unit, reference range, status, date, and its own links (the report
it was read from, the visits it is filed against). Every link to a lab result
(from a document, a visit, a condition or a medication) uses the reading id,
so following a report's link lands on the reading that report holds rather
than on a later draw. Each reading in `get_labs` carries the same id.
Condition and vaccination notes are never searched or returned, the same
rule the Coach and the doctor report follow. A deleted practitioner is not
named or matched. Query words shorter than four letters match whole words or
+3134 -859
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -284,3 +284,14 @@ earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
A case resets or states what it reads: `vi.resetAllMocks()` (or
`mockReset()`) where a case swaps in its own implementation, since
`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed
answers the next case; a module cache dropped through its own invalidator; a
row the case needs written by the case or its own `beforeAll`. A file that is
one walk by design (a parity sequence, a migration's before and after, a
dry run followed by the import and the same file again) says so in a comment
and passes `{ shuffle: false }` to its `describe`, which keeps that block in
order while the rest of the suite still shuffles. Wrap the whole file in one
such block when its top-level blocks build on each other.
+12
View File
@@ -216,6 +216,18 @@ JSON surfaces send the standard
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**Azure OpenAI.** Point the gateway at the resource's v1 endpoint,
`https://<resource>.openai.azure.com/openai/v1`, and put the
deployment name in Model. A reasoning model (gpt-5 on, the o-series)
takes `max_completion_tokens` and no `temperature`, the same as on
`api.openai.com`. HealthLog sends that from the first request when the
deployment is named after its model (`gpt-6-luna-eu`). A deployment
with a name of its own (`prod-chat`) gets the usual `max_tokens`
once; Azure refuses it, and the request goes out again with
`max_completion_tokens`. HealthLog remembers that for the endpoint and
deployment until the container restarts. Any other gateway that
refuses `max_tokens` the same way is handled the same way.
**When the gateway refuses.** OpenRouter answers a refused generation
with HTTP 200 and the reason inside the body, so every refusal used to
reach the provider health card as an empty reply with no status. It now
+4 -4
View File
@@ -314,7 +314,7 @@ id for debugging.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
to Apple Health automatically, so the import path on this page is how Garmin data
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
what Garmin withholds.
Garmin Connect writes to Apple Health automatically, so the import path on this
page brings Garmin data in on iOS. The direct [Garmin](./garmin.md) integration
reads more (Body Battery, stress, Garmin's sleep score) with a key from your
own Garmin account.
+1
View File
@@ -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 |
+102 -40
View File
@@ -1,52 +1,114 @@
# Garmin (via Apple Health or Google Health Connect)
# Garmin
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
there cannot be one.** Garmin's data-sharing program is business-partner-only:
it is approval-gated, requires a public callback endpoint, and is not the
"register your own app, paste a key" model the wearable integrations here rely
on. So instead of a broken half-integration, HealthLog reads Garmin data the
honest way — through the phone health platform your Garmin already feeds.
HealthLog reads Garmin Connect directly through Garmin's **Personal API**: you
create a key for your own account in Garmin Connect, paste it into HealthLog,
and the server pulls your data every hour. Nothing is pushed by Garmin, and
nothing is written back.
## The path that works
The Personal API is for your own data only. Each person on an instance
creates and pastes their own key; there is no shared app and no operator
setup.
```
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
→ Google Health Connect ├─→ HealthLog
(Android) ─┘
```
## Create a key
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
the native client's background sync).
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
Health Connect**. Connect HealthLog through the existing
[Google Health](./google-health.md) integration.
1. Open [Garmin Connect → Settings → Personal API](https://connect.garmin.com/app/settings/personalApiToken).
2. Create a key and choose how long it stays valid: 30, 90, 180 days or one
year.
3. Copy it right away. Garmin shows the key once.
No extra code, no Garmin developer account, no approval queue — if your Garmin
already syncs to your phone's health platform, HealthLog already reads it.
## Connect
Open **Settings → Integrations → Garmin**, paste the key, and pick the same
validity you chose in Garmin Connect. HealthLog checks the key with Garmin
before storing anything, so a mistyped or revoked key is refused on the spot.
The key is stored encrypted at rest and is never shown again, logged or sent
anywhere but Garmin.
Accounts registered with Garmin's China service work too: pick **Garmin
Connect China** as the service. The key is only ever sent to the service you
pick.
After connecting, the last 30 days arrive within a minute and the rest of your
history follows in the background. The card says "Older history is still
loading" until it is done.
## Key expiry
Garmin keys expire and cannot be extended. Garmin does not report a key's end
date through the API, so HealthLog works it out from the validity you picked
at connect, and replaces that estimate with Garmin's own date once the key is
in its last day (the short-lived tokens it buys are cut off at the key's end).
From seven days before the end the card says when the key expires. When it
has expired, or you revoked it in Garmin Connect, syncing stops and the card
asks for a new key: create one, open **Replace key** in the card's menu and
paste it. Nothing imported is lost. If you did not pick a validity, the card
says that it cannot warn ahead; **Key validity** in the card's menu sets it
afterwards, together with the day you created the key, without pasting the
key again. A key Garmin turned down is asked about again once a day, so a
refusal that passes on its own clears without you doing anything.
## What comes through
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
light), calories (active and total), distance, weight and body composition,
cadence, and — partially — blood-oxygen saturation.
| Garmin data | HealthLog type | Unit |
| --------------------------------------------------------------------- | -------------------------- | ------------- |
| Steps | `ACTIVITY_STEPS` | steps |
| Step distance | `WALKING_RUNNING_DISTANCE` | m |
| Floors climbed | `FLIGHTS_CLIMBED` | flights |
| Active calories | `ACTIVE_ENERGY_BURNED` | kcal |
| Resting heart rate | `RESTING_HEART_RATE` | bpm |
| All-day average stress level | `STRESS_LEVEL` | 0–100 |
| Body Battery (the day's high) | `BODY_BATTERY` | 0–100 |
| Sleep stages (light, deep, REM, awake) | `SLEEP_DURATION` per stage | minutes |
| Sleep score | `SLEEP_SCORE` | 0–100 |
| Average overnight SpO₂ | `OXYGEN_SATURATION` | % |
| Average overnight breathing rate | `RESPIRATORY_RATE` | breaths/min |
| Overnight HRV (RMSSD) | `HRV_RMSSD` | ms |
| Weight, BMI, body fat, body water, muscle and bone mass, visceral fat | the body-composition types | kg, %, rating |
| Activities | workouts | |
| Logged period days | the cycle tracker | |
## What Garmin withholds
Notes:
These are Garmin's **proprietary** scores. Garmin does **not** share them with
Apple Health or Health Connect, so no self-hosted integration — and no amount of
configuration — can obtain them. They are only available inside Garmin Connect
itself:
- Garmin's HRV is RMSSD and lands on `HRV_RMSSD`. Garmin reports no SDNN, so
nothing is written to `HEART_RATE_VARIABILITY`.
- Stress and Body Battery are Garmin's own scores and keep types of their own.
They are not mixed into HealthLog's computed stress score.
- Days you did not wear the watch import nothing, not zeros.
- Period days are only added on days you have not logged yourself; your own
entries are never changed. Cycles Garmin only predicts are not imported.
- Weigh-ins are read back to the start of Garmin Connect, also from before
your first watch, so a scale-only account gets its whole history.
- A night you delete in Garmin Connect, or that Garmin turns into a nap, is
removed here on the next sync.
- Data Garmin does not provide for your account (a menstrual-cycle summary
when you track none, for example) is skipped, does not count as an error,
and is listed under **Details for a bug report** on the card.
- When the same data also arrives through Apple Health or Health Connect, the
source order in **Settings → Sources** decides which one a chart shows.
Garmin ranks with the other watches.
- Body Battery
- HRV Status
- VO₂ max
- Training Load and Training Effect
- Recovery Time
- Intensity Minutes
- Stress score
- Running dynamics
## What the Personal API does not carry
If you rely on those specific numbers, keep the Garmin Connect app for them;
everything else lands in HealthLog through the platform path above.
Training Readiness, Training Status, acute training load and recovery time
are not part of the Personal API. If you read them elsewhere, for example
through a Home Assistant integration, push them through an ingest token: see
[Home Assistant](./home-assistant.md#garmin-training-figures). They land as
`TRAINING_READINESS` and `ACUTE_TRAINING_LOAD` and show on the recovery page
beside Body Battery.
## Limits
- Garmin sets how many requests a key may make. HealthLog reads one resource
at a time and stops the hour's run at Garmin's first rate-limit answer; the
history backfill waits as long as Garmin asks before it goes on.
- Disconnecting removes the key from HealthLog. It stays valid at Garmin until
you revoke it there.
## Without a key
Garmin Connect also writes to Apple Health on iOS and to Google Health Connect
on Android, so the [Apple Health](./apple-health.md) and
[Google Health](./google-health.md) paths keep working for anyone who prefers
not to create a key. Body Battery, stress and Garmin's sleep score only come
through the Personal API.
+4 -4
View File
@@ -315,7 +315,7 @@ Previously synced measurements stay in the database.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
what comes through and what Garmin withholds.
Since Garmin Connect 5.14.1 (July 2025) the app writes to Google Health Connect,
so this path brings Garmin data in on Android. The direct [Garmin](./garmin.md)
integration reads more (Body Battery, stress, Garmin's sleep score) with a key
from your own Garmin account.
+32
View File
@@ -149,6 +149,38 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Garmin training figures
The Garmin Personal API does not carry Training Readiness or the acute
training load (see [Garmin](./garmin.md)). If Home Assistant reads them, for
example through a Garmin Connect integration, send them through the same
ingest token. They have types of their own and appear on the recovery page:
| What the sensor reports | `type` | Unit expected |
| -------------------------- | --------------------- | ------------- |
| Garmin Training Readiness | `TRAINING_READINESS` | score 0–100 |
| Garmin acute training load | `ACUTE_TRAINING_LOAD` | load, no unit |
Both change about once a day. This automation pushes Training Readiness
whenever the sensor changes, through the `healthlog_measurement` command
above; the acute load is the same with its own sensor and type:
```yaml
automation:
- alias: "HealthLog: Garmin training readiness"
triggers:
- trigger: state
entity_id: sensor.garmin_training_readiness
conditions:
- condition: template
value_template: "{{ trigger.to_state.state | is_number }}"
actions:
- action: rest_command.healthlog_measurement
data:
type: TRAINING_READINESS
value: "{{ trigger.to_state.state | float }}"
```
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
+19
View File
@@ -140,6 +140,25 @@ An INVALID index whose migration is already recorded applied is not touched at
boot. The boot names it on stderr with `WARNING — index … is INVALID`; rebuild
it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
### The app's database role
The steps above run as the owner `DATABASE_URL` names: the dump, the
migrations and, after them, the job queue's own install or migration and the
setup of `healthlog_app_<database>_<hash>`, the role the app then connects as
([database-role.md](../self-hosting/database-role.md)). Neither the app nor
its queue ever runs a migration. A migration that creates a table needs
nothing more: the setup grants the role every table it lacks before the app
starts.
The queue schema `pgboss` stays the owner's. When an update brings a newer
pg-boss with a schema migration, the start runs it as the owner, including
its index builds, before the app starts; on a large queue that start takes as
long as the builds. Stop the old container (and a separate worker) before the
new one starts, as for any update with migrations. A web container and a
worker container that start together wait for each other on an advisory lock
and derive the same password. Going back to 1.46 runs as the owner, as 1.46
always did, and finds nothing in the queue schema it did not create.
### v1.46.0 notes
- **Postgres 18.** The compose file moves the bundled database from
+9
View File
@@ -147,6 +147,15 @@ different.
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
The restore runs as the owner `DATABASE_URL` names, and the restored tables
belong to it. The app connects as its own role,
`healthlog_app_<database>_<hash>`
([database-role.md](../self-hosting/database-role.md)): the start in step 5
grants that role the restored tables, the queue's included, before the app
runs; the queue schema stays the owner's. A dump carries grants to the role;
`db-restore` leaves them out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
the same two flags, or it fails on a database without the role.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
+11 -1
View File
@@ -214,7 +214,7 @@ So an index is retired in two halves (v1.43.8, migration 0416, for
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the
`src/lib/jobs/retired-measurement-indexes.mjs`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
@@ -242,6 +242,16 @@ SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
When the app connects as its restricted role
([database-role.md](../self-hosting/database-role.md)), the job may vacuum and
rebuild (`MAINTAIN`) but not drop: `DROP INDEX` belongs to the table's owner.
A run then skips the drops, of retired indexes and of the invalid copy a
failed rebuild leaves, names what it found under
`maintenance_drops_left_for_boot` with a warning, and the next start drops
each as the owner with `DROP INDEX CONCURRENTLY`, waiting at most five
seconds. Until then a failed rebuild's copy holds its disk; restart the
container, or drop it by hand as the owner with the statement above.
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+176
View File
@@ -0,0 +1,176 @@
# The app's database role
The container connects to Postgres under two names. The user in
`DATABASE_URL` (on the bundled compose file, `healthlog`, which the official
Postgres image makes a superuser) owns the schema: the entrypoint uses it for
the dump before an update, the migrations, the job queue's own install and
migrations, and the restore. The app itself, web and worker, connects as a
role the entrypoint sets up for this database after the migrations on every
start. Nothing to configure: the first start of a release with this change
creates it.
The point is the blast radius of a bug. If a dependency of the app were ever
made to run code inside the container, the database connection it found would
be one that can read and write rows, not one that can drop tables, rewrite the
schema, plant code for the owner to run, or reach the server's files.
## Its name
`healthlog_app_<database>_<hash>`: the database's name, cut to letters,
digits and underscores, and eight hex digits of a hash of it. On the bundled
compose file that is `healthlog_app_healthlog_` and the hash. The start log
names it, and so does `pg_stat_activity`.
Postgres roles belong to the whole server, not to one database. One role per
database keeps two HealthLog instances on one server (production and staging
on a shared Postgres) apart: each has its own role and password, neither
locks the other out, and neither role has any grant in the other database. A
role that holds an explicit `CONNECT` grant on another database is not used
(the start says so). Postgres lets every role connect to a database whose
`CONNECT` is granted to `PUBLIC`, the default; the role finds nothing to read
there. To shut the door as well, revoke it on each HealthLog database:
`REVOKE CONNECT ON DATABASE <name> FROM PUBLIC;` (and grant it back to any
monitoring user that relied on it).
## What the role may and may not do
| May | May not |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------- |
| read, insert, update and delete rows in every table of the app's schema | create, alter or drop tables, indexes, types or functions anywhere |
| use and advance its sequences | write `_prisma_migrations` (it may read it) |
| `VACUUM`, `ANALYZE` and `REINDEX` the `measurements` table (`MAINTAIN`) | create triggers or rules, own anything |
| read and write the job queue's rows in `pgboss` | write `pgboss.bam` or the version in `pgboss.version` (it may read) |
| create temporary tables | create roles, schemas or databases; become the owner (`SET ROLE`) |
| | act as a superuser: no `COPY … PROGRAM`, no `pg_read_file` |
The start also takes `CREATE` on the app's schema away from `PUBLIC`, which
Postgres 15 and newer already do for a new database but a database first
created on an older major still allows.
## The job queue's schema
`pgboss` belongs to the owner. pg-boss changes its schema when it installs,
when it migrates (and in the index builds a migration leaves in
`pgboss.bam`), and for queues created with `partition: true`, which HealthLog
does not use. So the start installs and migrates the queue schema as the
owner, with pg-boss's own plans, before the app runs, and the worker starts
pg-boss with `migrate: false`: it only reads and writes rows, and refuses to
run on a schema that is not at its version rather than change it. A new queue
is a row.
The role may not write the two tables whose contents decide what runs as the
owner: `pgboss.bam` holds SQL that pg-boss's migration runner executes, and
the version in `pgboss.version` decides which migrations run. Owning nothing
in the schema, the role cannot plant a trigger, a rule or a function body
that a later session as the owner would run with the owner's rights: a
rollback to 1.46 (which runs as the owner throughout), `DB_APP_ROLE=off`, a
container that fell back to the owner, or the old container during a deploy.
For the same reason a container that falls back to the owner and one that
runs as the role can share the schema: the owner creates every object in it
either way, and the next start grants the role what is new.
Every start, on every path (the role, `DB_APP_ROLE=off`, a fallback), also
takes back anything in `pgboss` that a HealthLog-made role owns and drops any
trigger or rule there (pg-boss defines none). A pre-release build handed the
schema to a server-wide `healthlog_app` role; when the start finds that, it
also compares every queue function with pg-boss's own definition and puts
back the ones that differ, retires that role's grants in this database, and
drops it once no database refers to it. The log names each.
pg-boss rebuilds bloated queue indexes only on tables it owns; under the role
it reports them. The start rebuilds them as the owner, one at a time with a
five-second lock wait.
## The password
Derived from `API_TOKEN_HMAC_KEY` and the database's name, so a separate
worker container ([scaling.md](scaling.md)) arrives at the same one without a
new variable, and stored as a SCRAM hash. Rotating `API_TOKEN_HMAC_KEY` with a
web/worker split means restarting all containers together: the first one to
start sets the new password, and a container still on the old key cannot
open new connections until it restarts with the new one.
The owner's URL is replaced, not kept beside the role's, so it is not in the
app's environment. `docker compose exec app …` still starts with the
container's configured environment, so the operator scripts
(`reset-password.mjs` and the others) run as the owner as before.
Each start re-applies the grants, so a table the new migrations created is
covered before the app starts, and a restored database is put right on the
start after the restore.
## What the start log says
When it is in place:
```text
HealthLog: the app connects as healthlog_app_healthlog_1a2b3c4d: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)
```
When it is not, one notice names the reason, and the app runs as the
`DATABASE_URL` user, exactly as every earlier release did:
```text
HealthLog: notice: the app connects as healthlog, the user DATABASE_URL names, not as a restricted role: <reason>. …
```
The reasons:
- **The user may not create roles.** A managed database (RDS, Cloud SQL,
Supabase and the like) or a database server where you gave HealthLog an
account without `CREATEROLE`. Either leave it, or create the split yourself
(below) and set `DB_APP_ROLE=off`.
- **Postgres older than 17.** The maintenance job's `VACUUM` and index rebuild
need the `MAINTAIN` privilege, which Postgres 17 introduced; before it only
the table's owner may run them. The bundled database is Postgres 18; see
[postgres-major-upgrade.md](../ops/postgres-major-upgrade.md) for one you
run yourself.
- **The role cannot log in.** A `pg_hba.conf` that lists users by name needs a
line for the role beside the one for the owner.
- **The role holds `CONNECT` on another database.** Revoke it there.
- **A role of that name exists that HealthLog did not create** for this
database. The start leaves a role it does not recognise alone.
- **The queue schema is newer than this image's pg-boss**, after going back
to an older image: only the owner runs on it.
- **Setting it up failed**, most often a lock it waited 15 seconds for while
another container held it. The next start tries again.
None of these stops the start.
## Turning it off
Set `DB_APP_ROLE=off` in `.env` and recreate the container. The app then
connects as the `DATABASE_URL` user. Use it when you manage database roles
yourself, including when `DATABASE_URL` already names a restricted role of
your own; then that role needs what the table above lists, and the
migrations and the queue's install need a user that owns the schema.
The role stays in the database when you turn it off and owns nothing. To
remove it, as the owner, with the name from the start log:
```sql
DROP OWNED BY healthlog_app_healthlog_1a2b3c4d;
DROP ROLE healthlog_app_healthlog_1a2b3c4d;
```
## Going back to 1.46
1.46 knows nothing of the role and runs as the owner, as it always did. The
queue schema is the owner's, so it finds nothing it did not create. The role
stays, unused, until you drop it as above.
## Index drops at start
`DROP INDEX` belongs to a table's owner; `MAINTAIN` does not include it. The
measurement maintenance therefore no longer drops a retired index or the
invalid copy a failed rebuild leaves; it names them on its run and the next
start drops them as the owner, waiting at most five seconds for each
([measurement-maintenance.md](../ops/measurement-maintenance.md)).
## Dumps and restores
The nightly dump runs as the role and reads everything through its grants. A
dump carries those grants; `db-restore` leaves them and the ownership out
(`--no-owner --no-privileges`), restores as the owner, and the next start
grants again. A restore of your own with `pg_restore` should do the same, or
it fails on a database without the role.
+16 -8
View File
@@ -9,7 +9,15 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work
unchanged. The image is built on the official Node 22 image (Debian
slim, glibc). Document thumbnails and
scanned-PDF rendering use a native renderer that runs on every arm64
board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86
CPU (Celeron/Atom-class NAS chips) it switches itself off and those two
features fall back to text only. `NATIVE_CANVAS=off` or `on` in `.env`
overrides that detection (`on` crashes PDF processing on a CPU that truly
lacks the instructions).
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
@@ -64,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`
@@ -281,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
@@ -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
+43
View File
@@ -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
+11 -1
View File
@@ -129,9 +129,19 @@ looks healthy. Deploy both from the same image tag
(`HEALTHLOG_IMAGE_REF` is shared by `app` and the `app-worker` block)
and restart the worker together with the web containers.
## Database role
Both containers run the entrypoint, and both set up the database role the
app connects as ([database-role.md](database-role.md)). They take turns on an
advisory lock and derive the same password from `API_TOKEN_HMAC_KEY` and the
database's name, which the shared environment block gives both, so a worker
that starts after the web container changes nothing the web container relies
on. Rotating `API_TOKEN_HMAC_KEY` changes that password: restart all
containers together.
## Healthchecks
- `app` (web) healthcheck: `wget /api/health` every 30s.
- `app` (web) healthcheck: a `node -e` fetch of `/api/health` every 30s.
- `app-worker` does not expose a port; its liveness is tracked
in-memory inside the process (`src/lib/jobs/worker-status.ts`) and
read back by `/api/admin/status`. In a web/worker split this means
+55
View File
@@ -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.
+7 -5
View File
@@ -287,7 +287,6 @@ async function chooseStandardPdfScope(page: Page): Promise<void> {
interface GeneratedReport {
status: number;
contentType: string;
contentLength: string | undefined;
bytes: Buffer;
text: string;
filename: string;
@@ -348,7 +347,6 @@ async function generateReport(page: Page): Promise<GeneratedReport> {
return {
status: response.status(),
contentType: response.headers()["content-type"] ?? "",
contentLength: response.headers()["content-length"],
bytes,
text,
filename: download.suggestedFilename(),
@@ -370,9 +368,13 @@ function expectRealPdf(report: GeneratedReport): void {
expect(report.contentType).toContain("application/pdf");
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
// The route declares the length it sent; a disagreement means the body was
// truncated on the way out, which a magic-number check alone would miss.
expect(report.contentLength).toBe(String(report.bytes.byteLength));
// A body truncated on the way out keeps its header but loses the trailer,
// which a magic-number check alone would miss. The route's Content-Length
// cannot say this: Next drops that header from every route response since
// 16.4.5, so the bytes have to.
expect(report.bytes.subarray(-1024).toString("latin1").trimEnd()).toMatch(
/%%EOF$/,
);
// The name the CLIENT anchor invents, not the route's `Content-Disposition`
// — the two differ by a word today. This says the panel saved a PDF, and it
// is not contract coverage of the header.
+43
View File
@@ -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 ({
+104
View File
@@ -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<string, unknown> } };
};
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([]);
});
});
+46
View File
@@ -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");
});
});
+7
View File
@@ -261,10 +261,17 @@ test.describe("score histories", () => {
await expect(
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
// The choice is saved by a PUT; reloading before it answers cancels it.
const saved = page.waitForResponse(
(res) =>
res.url().includes("/api/dashboard/chart-overlay-prefs") &&
res.request().method() === "PUT",
);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
expect((await saved).status()).toBe(200);
await page.reload();
await expectSharedTabs(page, chart, "7");
await resetRange(page, "scoreRecovery");
+1 -1
View File
@@ -160,7 +160,7 @@ test.describe("Settings → Export & Import", () => {
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("2/15");
).toContainText("2/16");
await expect(
page.getByTestId("report-group-row-mobility-export"),
).toContainText("0/11");
+94 -1
View File
@@ -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
@@ -865,8 +868,18 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
await target.focus();
const tip = page.locator('[data-slot="timeline-point-tip"]');
// 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");
@@ -909,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,
}) => {
+63 -1
View File
@@ -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<TimelineMockLog> {
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
+231 -23
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Herzfrequenzvariabilität",
"typeRestingHeartRate": "Ruheherzfrequenz",
"typeActiveEnergyBurned": "Aktive Energie",
"typeBasalEnergyBurned": "Ruheenergie",
"typeFlightsClimbed": "Treppen",
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
"typeVo2Max": "VO₂max",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} Min.",
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Stresslevel",
"typeTrainingReadiness": "Trainingsbereitschaft",
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Geplant",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Quelle",
"intakeHistoryInjectionSite": "Injektionsstelle: {site}",
"intakeHistoryStatusTaken": "Eingenommen",
"intakeHistoryStatusSkipped": "Übersprungen",
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
@@ -4758,7 +4765,8 @@
"unitScore": "Punkte",
"unitMinutes": "Min.",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "Belastung"
},
"sleepQuality": {
"title": "Schlafqualität",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet.",
"bodyBatteryExplainer": "Deine Energiereserve auf Garmins Skala von 0 bis 100, der höchste Stand des Tages. Ruhe und Schlaf laden sie auf, Stress und Bewegung zehren davon.",
"trainingReadinessExplainer": "Wie bereit dein Körper für eine harte Einheit ist, auf Garmins Skala von 0 bis 100. Der Wert kommt über eine von dir eingerichtete Brücke, weil Garmin ihn nicht direkt teilt.",
"stressLevelExplainer": "Garmins durchschnittliches Stresslevel des Tages auf einer Skala von 0 bis 100, abgeleitet aus deiner Herzfrequenzvariabilität. Niedriger heißt ruhiger.",
"acuteTrainingLoadExplainer": "Garmins akute Trainingsbelastung: die gewichtete Belastung deiner Aktivitäten der letzten sieben Tage. Der Wert kommt über eine von dir eingerichtete Brücke. Vergleiche ihn mit deinen eigenen letzten Wochen."
},
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "In diesem Zeitraum wurde noch kein Score erfasst.",
"usualRange": "Schattiert: dein üblicher Bereich, {lo} bis {hi}.",
"seam": "Die Linie bricht dort ab, wo sich geändert hat, was in den Score einfließt."
},
"bpTimeOfDay": {
"title": "Nach Tageszeit",
"description": "Dein durchschnittlicher Blutdruck je Tageszeit im Zeitraum des Diagramms.",
"part": {
"morning": "Morgens",
"midday": "Mittags",
"evening": "Abends"
},
"filterAll": "Ganzer Tag",
"filterLabel": "Im Diagramm zeigen",
"readingsOne": "{count} Messung",
"readingsFew": "{count} Messungen",
"readingsOther": "{count} Messungen",
"notEnough": "Zu wenige Messungen",
"pulse": "Puls {value} bpm",
"difference": "Morgens minus abends: {systolic}/{diastolic} mmHg.",
"differencePending": "Morgen und Abend werden verglichen, sobald beide mindestens {min} Messungen haben.",
"hours": "Morgens {morning}, mittags {midday}, abends {evening}, in deiner Zeitzone.",
"hourRange": "{start} bis {end} Uhr",
"emptyTitle": "Keine vollständigen Messungen in diesem Zeitraum",
"emptyDescription": "Hier erscheinen Messungen mit systolischem und diastolischem Wert.",
"error": "Die Werte nach Tageszeit konnten nicht geladen werden.",
"chartTitle": {
"morning": "Blutdruck morgens",
"midday": "Blutdruck mittags",
"evening": "Blutdruck abends"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "morgens",
"midday": "mittags",
"evening": "abends"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5653,7 +5702,11 @@
"recovery": "Erholung",
"stress": "Stress",
"sleepDebt": "Schlafdefizit",
"hrvRmssd": "HRV (RMSSD)"
"hrvRmssd": "HRV (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Stresslevel",
"trainingReadiness": "Trainingsbereitschaft",
"acuteTrainingLoad": "Akute Trainingsbelastung"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Externes Gerät",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Uhr",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte (Body Battery, Training Load, HRV-Status, VO₂max) werden von Garmin an keine der beiden Plattformen weitergegeben.",
"docsLink": "Zur Anleitung"
},
"glucoseReference": {
"title": "Glukose-Referenzband",
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountScheduled": "Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
"dangerZoneSuccessGrace": "Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Füge einen Garmin-Personal-API-Schlüssel ein, um Schlaf, HRV, Body Battery, Stress, Schritte, Gewicht und Aktivitäten aus Garmin Connect zu synchronisieren.",
"garminSync": "Jetzt synchronisieren",
"garminSyncResult": "{count} Einträge synchronisiert",
"garminSyncFailed": "Synchronisierung fehlgeschlagen",
"garminViewData": "Erholung ansehen",
"garminReplaceKey": "Schlüssel ersetzen",
"garminDisconnect": "Trennen",
"garminDisconnectTitle": "Garmin trennen?",
"garminDisconnectDescription": "Künftige Synchronisierungen enden, und der Schlüssel wird von diesem Server entfernt. Bereits importierte Daten bleiben in deinem Verlauf. Bei Garmin bleibt der Schlüssel gültig, bis du ihn dort widerrufst.",
"garminKeyValidUntil": "Schlüssel gültig bis {date}",
"garminKeyExpired": "Dein Garmin-Schlüssel ist am {date} abgelaufen. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung weiterläuft.",
"garminKeyExpiresToday": "Dein Garmin-Schlüssel läuft heute ab. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung nicht stoppt.",
"garminKeyExpiring": "Dein Garmin-Schlüssel läuft am {date} ab. Erstelle vorher in Garmin Connect einen neuen Schlüssel und füge ihn hier ein.",
"garminKeyExpiryUnknown": "Garmin verrät nicht, wann dieser Schlüssel abläuft. Ersetze den Schlüssel und wähle die Gültigkeit, die du in Garmin Connect eingestellt hast, dann warnt dich diese Karte eine Woche vorher.",
"garminConnected": "Garmin verbunden. Die erste Synchronisierung läuft an.",
"garminKeyHowTo": "Öffne in Garmin Connect die Einstellungen, dann Personal API, und erstelle einen Schlüssel. Garmin zeigt ihn nur einmal an, kopiere ihn also sofort.",
"garminKeySettingsLink": "Garmin Connect öffnen",
"garminApiKey": "Personal-API-Schlüssel",
"garminApiKeyPlaceholder": "Schlüssel einfügen",
"garminApiKeySavedPlaceholder": "Gespeichert. Füge einen neuen Schlüssel ein, um ihn zu ersetzen",
"garminKeyValidity": "Gewählte Gültigkeit",
"garminKeyValidityUnknown": "Weiß ich nicht",
"garminKeyValidityDays": "{count} Tage",
"garminKeyValidityHelp": "Garmin meldet nicht, wann ein Schlüssel abläuft. Mit der Gültigkeit warnt dich diese Karte eine Woche, bevor der Schlüssel nicht mehr funktioniert.",
"garminConnect": "Verbinden",
"garminUpdate": "Änderungen speichern",
"garminRegion": "Garmin-Dienst",
"garminRegionGlobal": "Garmin Connect (global)",
"garminRegionChina": "Garmin Connect China",
"garminErrors": {
"keyRefused": "Garmin hat diesen Schlüssel nicht angenommen. Prüfe, ob er vollständig kopiert ist, zum gewählten Dienst gehört und weder abgelaufen noch widerrufen ist.",
"rateLimited": "Garmin begrenzt gerade die Anfragen. Versuche es in ein paar Minuten noch einmal.",
"unreachable": "Garmin ist nicht erreichbar. Versuche es in ein paar Minuten noch einmal.",
"invalid": "Dieser Schlüssel sieht nicht richtig aus. Füge ihn noch einmal ohne Leerzeichen ein.",
"generic": "Das hat nicht geklappt. Versuche es noch einmal."
},
"garminKeyValidityMenu": "Gültigkeit des Schlüssels",
"garminValiditySaved": "Gültigkeit gespeichert.",
"garminKeyCreatedOn": "Erstellt am",
"garminKeyCreatedOnHelp": "Der Tag, an dem du den Schlüssel in Garmin Connect erstellt hast. Leer gelassen zählt die Gültigkeit ab dem Tag, an dem du ihn hier verbunden hast.",
"garminUnavailable": "Garmin stellt für dein Konto diese Daten nicht bereit: {list}.",
"garminResource": {
"daily": "Tagesübersichten",
"activities": "Aktivitäten",
"sleep": "Schlaf",
"hrv": "HRV",
"weight": "Wiegungen",
"cycle": "Menstruationszyklus"
}
},
"admin": {
"title": "Administration",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "Datei ist zu groß (max. 12 MB).",
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
"extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
"pdfRendererUnavailable": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen. Einen gescannten Befund kann hier nur ein KI-Anbieter lesen, der PDF-Dateien direkt annimmt. Lade stattdessen ein Foto hoch.",
"refTextLabel": "Bereich wie gedruckt",
"refTextPlaceholder": "z. B. 3,5 - 5,0",
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar."
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar.",
"errorUnitMismatchNoUnit": "Dieser Marker wird ohne Einheit geführt. Setz das Häkchen bei Keine Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
"unitDiffersBadgeNoUnit": "Ohne Einheit geführt; dieser Befund nennt {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion."
},
"interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}."
},
"unit": {
"none": "Keine Einheit",
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "Die gespeicherte Datei konnte zum Lesen nicht geöffnet werden.",
"raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.",
"provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.",
"pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.",
"pdf-needs-anthropic": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen, und dieses PDF hat keine Textebene. Lesen kann es ein KI-Anbieter, der PDF-Dateien direkt annimmt, oder du lädst Fotos der Seiten hoch.",
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert.",
"resource-limit": "Dieses PDF ist zu groß oder zu komplex, um es hier zu lesen. Die Datei selbst ist in Ordnung und lässt sich weiterhin öffnen."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
"errorRateLimitedWaitOther": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
"errorPdfRendererUnavailable": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen. Ein gescanntes PDF kann hier nur ein KI-Anbieter lesen, der PDF-Dateien direkt annimmt, oder du lädst Fotos der Seiten hoch.",
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "Bei Bedarf",
"startsOn": "Beginn",
"endsOn": "Ende"
}
},
"unitlessHint": "Ein Quotient oder Index ohne Einheit (INR, Cholesterin/HDL): Trag in der Spalte für die Einheit eine 1 ein. Eine leere Einheit bleibt ein Fehler."
}
}
+231 -23
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
"durationTooLong": "One entry can't be longer than 24 hours.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Stress level",
"typeTrainingReadiness": "Training readiness",
"typeAcuteTrainingLoad": "Acute training load",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "What HealthLog can track",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryInjectionSite": "Injection site: {site}",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
@@ -4758,7 +4765,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "load"
},
"sleepQuality": {
"title": "Sleep quality",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively.",
"bodyBatteryExplainer": "Your energy reserve on Garmin's 0 to 100 scale, the day's highest charge. Rest and sleep fill it, stress and activity drain it.",
"trainingReadinessExplainer": "How ready your body is for a hard session, on Garmin's 0 to 100 scale. It arrives from a bridge you set up, since Garmin does not share it directly.",
"stressLevelExplainer": "Garmin's all-day average stress level on a 0 to 100 scale, read from your heart rate variability. Lower is calmer.",
"acuteTrainingLoadExplainer": "Garmin's acute training load: the weighted load of your last seven days of activity. It arrives from a bridge you set up. Compare it with your own recent weeks."
},
"loadError": "Your recovery readings could not be loaded."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "No score was recorded in this range yet.",
"usualRange": "Shaded: your usual range, {lo} to {hi}.",
"seam": "The line breaks where what counts toward the score changed."
},
"bpTimeOfDay": {
"title": "By time of day",
"description": "Your average blood pressure in each part of the day, over the chart's range.",
"part": {
"morning": "Morning",
"midday": "Midday",
"evening": "Evening"
},
"filterAll": "All day",
"filterLabel": "Show in the chart",
"readingsOne": "{count} reading",
"readingsFew": "{count} readings",
"readingsOther": "{count} readings",
"notEnough": "Too few readings",
"pulse": "Pulse {value} bpm",
"difference": "Morning minus evening: {systolic}/{diastolic} mmHg.",
"differencePending": "Morning and evening are compared once each has at least {min} readings.",
"hours": "Morning {morning}, midday {midday}, evening {evening}, in your time zone.",
"hourRange": "{start}–{end}",
"emptyTitle": "No complete readings in this range",
"emptyDescription": "Readings with both a systolic and a diastolic value show up here.",
"error": "The time-of-day averages could not be loaded.",
"chartTitle": {
"morning": "Blood pressure in the morning",
"midday": "Blood pressure at midday",
"evening": "Blood pressure in the evening"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "morning",
"midday": "midday",
"evening": "evening"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5653,7 +5702,11 @@
"recovery": "Recovery",
"stress": "Stress",
"sleepDebt": "Sleep debt",
"hrvRmssd": "HRV (RMSSD)"
"hrvRmssd": "HRV (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Stress level",
"trainingReadiness": "Training readiness",
"acuteTrainingLoad": "Acute training load"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "External device",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Watch",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Last entered here {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores (Body Battery, Training Load, HRV Status, VO₂ max) are not shared by Garmin to either platform.",
"docsLink": "Read the guide"
},
"glucoseReference": {
"title": "Glucose reference band",
"description": "Which band your blood-glucose readings are judged against.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "You are signed out and your account is locked right away. After {days} days it is erased for good, with all health data, passkeys, sessions and audit entries. Until then, an admin of this server can undo the deletion. Copies in this server's backups age out after their retention period.",
"deleteAccountScheduled": "Your account is locked and will be erased on {date}. Signing you out.",
"dangerZoneSuccessGrace": "Your health record has been deleted. An admin of this server can restore it until {date}.",
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again."
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Paste a Garmin Personal API key to sync sleep, HRV, Body Battery, stress, steps, weight and activities from Garmin Connect.",
"garminSync": "Sync now",
"garminSyncResult": "{count} entries synchronized",
"garminSyncFailed": "Sync failed",
"garminViewData": "View your recovery",
"garminReplaceKey": "Replace key",
"garminDisconnect": "Disconnect",
"garminDisconnectTitle": "Disconnect Garmin?",
"garminDisconnectDescription": "Future syncs stop and the key is removed from this server. Data already imported stays in your history. The key keeps working at Garmin until you revoke it there.",
"garminKeyValidUntil": "Key valid until {date}",
"garminKeyExpired": "Your Garmin key expired on {date}. Create a new key in Garmin Connect and paste it here to resume syncing.",
"garminKeyExpiresToday": "Your Garmin key expires today. Create a new key in Garmin Connect and paste it here so syncing does not stop.",
"garminKeyExpiring": "Your Garmin key expires on {date}. Create a new key in Garmin Connect and paste it here before then.",
"garminKeyExpiryUnknown": "Garmin does not say when this key expires. Replace the key and choose the validity you picked in Garmin Connect, and this card will warn you a week ahead.",
"garminConnected": "Garmin connected. The first sync is on its way.",
"garminKeyHowTo": "In Garmin Connect, open Settings, then Personal API, and create a key. Garmin shows it only once, so copy it straight away.",
"garminKeySettingsLink": "Open Garmin Connect",
"garminApiKey": "Personal API key",
"garminApiKeyPlaceholder": "Paste the key",
"garminApiKeySavedPlaceholder": "Saved. Paste a new key to replace it",
"garminKeyValidity": "Validity you picked",
"garminKeyValidityUnknown": "I don't know",
"garminKeyValidityDays": "{count} days",
"garminKeyValidityHelp": "Garmin does not report when a key expires. With the validity, this card warns you a week before the key stops working.",
"garminConnect": "Connect",
"garminUpdate": "Save changes",
"garminRegion": "Garmin service",
"garminRegionGlobal": "Garmin Connect (global)",
"garminRegionChina": "Garmin Connect China",
"garminErrors": {
"keyRefused": "Garmin did not accept this key. Check that it was copied whole, belongs to the chosen service and has not expired or been revoked.",
"rateLimited": "Garmin is limiting requests right now. Try again in a few minutes.",
"unreachable": "Garmin cannot be reached. Try again in a few minutes.",
"invalid": "That key does not look right. Paste it again, without spaces.",
"generic": "That did not work. Try again."
},
"garminKeyValidityMenu": "Key validity",
"garminValiditySaved": "Validity saved.",
"garminKeyCreatedOn": "Created on",
"garminKeyCreatedOnHelp": "The day you created the key in Garmin Connect. Leave it empty to count from the day you connected it here.",
"garminUnavailable": "Garmin does not provide this data for your account: {list}.",
"garminResource": {
"daily": "daily summaries",
"activities": "activities",
"sleep": "sleep",
"hrv": "HRV",
"weight": "weigh-ins",
"cycle": "menstrual cycle"
}
},
"admin": {
"title": "Administration",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "File is too large (max 12 MB).",
"fileType": "Upload a JPEG, PNG, WebP, or PDF.",
"extractFailed": "Couldn't read the report. Try a clearer photo.",
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
"pdfRendererUnavailable": "This server's processor can't run the PDF page renderer, so a scanned report can only be read by an AI provider that takes PDF files directly. Upload a photo instead.",
"refTextLabel": "Range as printed",
"refTextPlaceholder": "e.g. 3.5 - 5.0",
"aiUnavailable": "Scanning a report with AI is not available here right now."
"aiUnavailable": "Scanning a report with AI is not available here right now.",
"errorUnitMismatchNoUnit": "This marker is tracked without a unit. Tick No unit if the report was misread, or deselect this reading.",
"unitDiffersBadgeNoUnit": "Tracked without a unit; this report says {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection."
},
"interval": "95 % interval of the yearly change: {lo} to {hi} {unit}."
},
"unit": {
"none": "No unit",
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "The stored file couldn't be opened for reading.",
"raster-failed": "The PDF pages couldn't be rendered for reading. Scanned fax compression formats are a known cause.",
"provider-error": "The AI provider couldn't read this document on the last try.",
"pdf-needs-anthropic": "The configured AI provider can't read PDF files. A Claude provider or local OCR can.",
"pdf-needs-anthropic": "This server's processor can't run the PDF page renderer, and this PDF has no text layer. An AI provider that takes PDF files directly can read it, or photos of the pages.",
"empty-transcription": "The AI provider returned no text for this document.",
"resource-limit": "This PDF is too large or complex to read here. The file itself is fine and still opens."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
"errorRateLimitedWaitOther": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
"errorBudget": "Your AI budget for today is used up.",
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
"errorPdfRendererUnavailable": "This server's processor can't run the PDF page renderer, so a scanned PDF can only be read by an AI provider that takes PDF files directly, or as photos of the pages.",
"errorFileType": "This file type can't be read.",
"errorNotIndexed": "Read the document first, then extract.",
"errorOutputLimit": "The AI provider reached its output limit before it returned a single value. Try a model that allows longer answers.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "As needed",
"startsOn": "Start",
"endsOn": "End"
}
},
"unitlessHint": "A ratio or an index without a unit (INR, cholesterol/HDL): write 1 in the unit column. An empty unit cell stays an error."
}
}
+231 -23
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
"durationTooLong": "Una entrada no puede durar más de 24 horas.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Nivel de estrés",
"typeTrainingReadiness": "Preparación para entrenar",
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Programada",
"intakeHistoryColStatus": "Estado",
"intakeHistoryColSource": "Fuente",
"intakeHistoryInjectionSite": "Lugar de inyección: {site}",
"intakeHistoryStatusTaken": "Tomada",
"intakeHistoryStatusSkipped": "Omitida",
"intakeHistorySortByTaken": "Ordenar por hora de toma",
@@ -4758,7 +4765,8 @@
"unitScore": "puntos",
"unitMinutes": "min",
"unitBpm": "ppm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carga"
},
"sleepQuality": {
"title": "Calidad del sueño",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa.",
"bodyBatteryExplainer": "Tu reserva de energía en la escala de 0 a 100 de Garmin, la carga más alta del día. El descanso y el sueño la llenan, el estrés y la actividad la vacían.",
"trainingReadinessExplainer": "Lo preparado que está tu cuerpo para una sesión dura, en la escala de 0 a 100 de Garmin. Llega a través de un puente que configuraste, porque Garmin no la comparte directamente.",
"stressLevelExplainer": "El nivel de estrés medio del día según Garmin, en una escala de 0 a 100, a partir de la variabilidad de tu frecuencia cardíaca. Más bajo es más tranquilo.",
"acuteTrainingLoadExplainer": "La carga de entrenamiento aguda de Garmin: la carga ponderada de tu actividad de los últimos siete días. Llega a través de un puente que configuraste. Compárala con tus propias semanas recientes."
},
"loadError": "No se pudieron cargar tus valores de recuperación."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "Todavía no hay ninguna puntuación registrada en este periodo.",
"usualRange": "Sombreado: tu rango habitual, de {lo} a {hi}.",
"seam": "La línea se interrumpe donde cambió lo que cuenta para la puntuación."
},
"bpTimeOfDay": {
"title": "Por momento del día",
"description": "Tu tensión arterial media en cada momento del día, en el periodo del gráfico.",
"part": {
"morning": "Mañana",
"midday": "Mediodía",
"evening": "Noche"
},
"filterAll": "Todo el día",
"filterLabel": "Mostrar en el gráfico",
"readingsOne": "{count} medición",
"readingsFew": "{count} mediciones",
"readingsOther": "{count} mediciones",
"notEnough": "Muy pocas mediciones",
"pulse": "Pulso {value} lpm",
"difference": "Mañana menos noche: {systolic}/{diastolic} mmHg.",
"differencePending": "La mañana y la noche se comparan cuando cada una tiene al menos {min} mediciones.",
"hours": "Mañana {morning}, mediodía {midday}, noche {evening}, en tu zona horaria.",
"hourRange": "{start}–{end}",
"emptyTitle": "No hay mediciones completas en este periodo",
"emptyDescription": "Aquí aparecen las mediciones con valor sistólico y diastólico.",
"error": "No se pudieron cargar los promedios por momento del día.",
"chartTitle": {
"morning": "Tensión arterial por la mañana",
"midday": "Tensión arterial a mediodía",
"evening": "Tensión arterial por la noche"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "por la mañana",
"midday": "al mediodía",
"evening": "por la noche"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5653,7 +5702,11 @@
"recovery": "Recuperación",
"stress": "Estrés",
"sleepDebt": "Deuda de sueño",
"hrvRmssd": "VFC (RMSSD)"
"hrvRmssd": "VFC (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Nivel de estrés",
"trainingReadiness": "Preparación para entrenar",
"acuteTrainingLoad": "Carga de entrenamiento aguda"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo externo",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Reloj",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Último registro aquí {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin (Body Battery, Training Load, estado de HRV, VO₂ máx.) no las comparte Garmin con ninguna de las dos plataformas.",
"docsLink": "Leer la guía"
},
"glucoseReference": {
"title": "Banda de referencia de glucosa",
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Se cierra tu sesión y tu cuenta se bloquea de inmediato. Al cabo de {days} días se borra para siempre, con todos tus datos de salud, llaves de acceso, sesiones y entradas de auditoría. Hasta entonces, un administrador de este servidor puede deshacer la eliminación. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
"deleteAccountScheduled": "Tu cuenta está bloqueada y se borrará el {date}. Cerrando tu sesión.",
"dangerZoneSuccessGrace": "Tu historial de salud se ha eliminado. Un administrador de este servidor puede restaurarlo hasta el {date}.",
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo."
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Pega una clave de la Garmin Personal API para sincronizar sueño, VFC, Body Battery, estrés, pasos, peso y actividades desde Garmin Connect.",
"garminSync": "Sincronizar ahora",
"garminSyncResult": "{count} entradas sincronizadas",
"garminSyncFailed": "Error de sincronización",
"garminViewData": "Ver tu recuperación",
"garminReplaceKey": "Reemplazar la clave",
"garminDisconnect": "Desconectar",
"garminDisconnectTitle": "¿Desconectar Garmin?",
"garminDisconnectDescription": "Las sincronizaciones futuras se detienen y la clave se elimina de este servidor. Los datos ya importados se quedan en tu historial. La clave sigue funcionando en Garmin hasta que la revoques allí.",
"garminKeyValidUntil": "Clave válida hasta el {date}",
"garminKeyExpired": "Tu clave de Garmin caducó el {date}. Crea una clave nueva en Garmin Connect y pégala aquí para reanudar la sincronización.",
"garminKeyExpiresToday": "Tu clave de Garmin caduca hoy. Crea una clave nueva en Garmin Connect y pégala aquí para que la sincronización no se detenga.",
"garminKeyExpiring": "Tu clave de Garmin caduca el {date}. Crea una clave nueva en Garmin Connect y pégala aquí antes de esa fecha.",
"garminKeyExpiryUnknown": "Garmin no indica cuándo caduca esta clave. Reemplaza la clave y elige la validez que seleccionaste en Garmin Connect, y esta tarjeta te avisará con una semana de antelación.",
"garminConnected": "Garmin conectado. La primera sincronización está en marcha.",
"garminKeyHowTo": "En Garmin Connect, abre Configuración, luego Personal API, y crea una clave. Garmin solo la muestra una vez, así que cópiala enseguida.",
"garminKeySettingsLink": "Abrir Garmin Connect",
"garminApiKey": "Clave de la Personal API",
"garminApiKeyPlaceholder": "Pega la clave",
"garminApiKeySavedPlaceholder": "Guardada. Pega una clave nueva para reemplazarla",
"garminKeyValidity": "Validez elegida",
"garminKeyValidityUnknown": "No lo sé",
"garminKeyValidityDays": "{count} días",
"garminKeyValidityHelp": "Garmin no informa de cuándo caduca una clave. Con la validez, esta tarjeta te avisa una semana antes de que la clave deje de funcionar.",
"garminConnect": "Conectar",
"garminUpdate": "Guardar cambios",
"garminRegion": "Servicio de Garmin",
"garminRegionGlobal": "Garmin Connect (global)",
"garminRegionChina": "Garmin Connect China",
"garminErrors": {
"keyRefused": "Garmin no aceptó esta clave. Comprueba que se copió entera, que pertenece al servicio elegido y que no ha caducado ni se ha revocado.",
"rateLimited": "Garmin está limitando las solicitudes. Inténtalo de nuevo en unos minutos.",
"unreachable": "No se puede contactar con Garmin. Inténtalo de nuevo en unos minutos.",
"invalid": "Esa clave no parece correcta. Pégala de nuevo, sin espacios.",
"generic": "No ha funcionado. Inténtalo de nuevo."
},
"garminKeyValidityMenu": "Validez de la clave",
"garminValiditySaved": "Validez guardada.",
"garminKeyCreatedOn": "Creada el",
"garminKeyCreatedOnHelp": "El día en que creaste la clave en Garmin Connect. Si lo dejas vacío, se cuenta desde el día en que la conectaste aquí.",
"garminUnavailable": "Garmin no ofrece estos datos para tu cuenta: {list}.",
"garminResource": {
"daily": "resúmenes diarios",
"activities": "actividades",
"sleep": "sueño",
"hrv": "VFC",
"weight": "pesajes",
"cycle": "ciclo menstrual"
}
},
"admin": {
"title": "Administration",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
"extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
"pdfRendererUnavailable": "El procesador de este servidor no puede renderizar páginas PDF, así que un informe escaneado solo lo puede leer un proveedor de IA que acepte archivos PDF directamente. Sube una foto en su lugar.",
"refTextLabel": "Rango tal como aparece",
"refTextPlaceholder": "p. ej. 3,5 - 5,0",
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo."
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo.",
"errorUnitMismatchNoUnit": "Este marcador se registra sin unidad. Marca Sin unidad si el informe se leyó mal o deselecciona este valor.",
"unitDiffersBadgeNoUnit": "Registrado sin unidad; este informe indica {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección."
},
"interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}."
},
"unit": {
"none": "Sin unidad",
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "El archivo guardado no se pudo abrir para su lectura.",
"raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura. Los formatos de compresión de fax escaneados son una causa conocida.",
"provider-error": "El proveedor de IA no pudo leer este documento en el último intento.",
"pdf-needs-anthropic": "El proveedor de IA configurado no puede leer archivos PDF. Un proveedor Claude o el OCR local sí pueden.",
"pdf-needs-anthropic": "El procesador de este servidor no puede renderizar páginas PDF y este PDF no tiene capa de texto. Puede leerlo un proveedor de IA que acepte archivos PDF directamente, o sube fotos de las páginas.",
"empty-transcription": "El proveedor de IA no devolvió texto para este documento.",
"resource-limit": "Este PDF es demasiado grande o complejo para leerlo aquí. El archivo en sí está bien y se sigue pudiendo abrir."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
"errorRateLimitedWaitOther": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
"errorPdfRendererUnavailable": "El procesador de este servidor no puede renderizar páginas PDF, así que un PDF escaneado solo lo puede leer un proveedor de IA que acepte archivos PDF directamente, o como fotos de las páginas.",
"errorFileType": "Este tipo de archivo no se puede leer.",
"errorNotIndexed": "Primero lee el documento y luego extrae.",
"errorOutputLimit": "El proveedor de IA alcanzó su límite de salida antes de devolver un solo valor. Prueba con un modelo que permita respuestas más largas.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "A demanda",
"startsOn": "Inicio",
"endsOn": "Fin"
}
},
"unitlessHint": "Un cociente o un índice sin unidad (INR, colesterol/HDL): escribe 1 en la columna de la unidad. Una unidad vacía sigue siendo un error."
}
}
+260 -52
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Saisissez une durée comme 7,5, 7:30 ou 7h 30m.",
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Niveau de stress",
"typeTrainingReadiness": "Disponibilité à l'entraînement",
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Programmée",
"intakeHistoryColStatus": "Statut",
"intakeHistoryColSource": "Source",
"intakeHistoryInjectionSite": "Site d'injection : {site}",
"intakeHistoryStatusTaken": "Prise",
"intakeHistoryStatusSkipped": "Ignorée",
"intakeHistorySortByTaken": "Trier par heure de prise",
@@ -2280,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",
@@ -2352,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",
@@ -3231,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": {
@@ -3331,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.",
@@ -3935,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).",
@@ -3989,7 +3996,7 @@
"bedtime": "Au coucher",
"unspecified": "Non précisée",
"readingsShort": "{count} mesures",
"readingsShortOne": "1 mesure"
"readingsShortOne": "{count} mesure"
}
}
}
@@ -4758,7 +4765,8 @@
"unitScore": "points",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "charge"
},
"sleepQuality": {
"title": "Qualité du sommeil",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement.",
"bodyBatteryExplainer": "Votre réserve d'énergie sur l'échelle de 0 à 100 de Garmin, le niveau le plus haut de la journée. Le repos et le sommeil la rechargent, le stress et l'activité la vident.",
"trainingReadinessExplainer": "À quel point votre corps est prêt pour une séance intense, sur l'échelle de 0 à 100 de Garmin. La valeur arrive par une passerelle que vous avez configurée, car Garmin ne la partage pas directement.",
"stressLevelExplainer": "Le niveau de stress moyen de la journée selon Garmin, sur une échelle de 0 à 100, tiré de la variabilité de votre fréquence cardiaque. Plus bas signifie plus calme.",
"acuteTrainingLoadExplainer": "La charge d'entraînement aiguë de Garmin : la charge pondérée de vos activités des sept derniers jours. Elle arrive par une passerelle que vous avez configurée. Comparez-la à vos propres semaines récentes."
},
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
},
@@ -4923,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"
@@ -4937,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",
@@ -4971,6 +4983,40 @@
"emptyDescription": "Aucun score n’a encore été enregistré sur cette période.",
"usualRange": "En grisé : votre plage habituelle, de {lo} à {hi}.",
"seam": "La ligne s’interrompt là où ce qui compte pour le score a changé."
},
"bpTimeOfDay": {
"title": "Selon le moment de la journée",
"description": "Votre tension artérielle moyenne à chaque moment de la journée, sur la période du graphique.",
"part": {
"morning": "Matin",
"midday": "Midi",
"evening": "Soir"
},
"filterAll": "Toute la journée",
"filterLabel": "Afficher dans le graphique",
"readingsOne": "{count} mesure",
"readingsFew": "{count} mesures",
"readingsOther": "{count} mesures",
"notEnough": "Trop peu de mesures",
"pulse": "Pouls {value} bpm",
"difference": "Matin moins soir : {systolic}/{diastolic} mmHg.",
"differencePending": "Le matin et le soir sont comparés dès que chacun compte au moins {min} mesures.",
"hours": "Matin {morning}, midi {midday}, soir {evening}, dans votre fuseau horaire.",
"hourRange": "{start}–{end}",
"emptyTitle": "Aucune mesure complète sur cette période",
"emptyDescription": "Les mesures avec une valeur systolique et diastolique apparaissent ici.",
"error": "Impossible de charger les moyennes selon le moment de la journée.",
"chartTitle": {
"morning": "Tension artérielle le matin",
"midday": "Tension artérielle à midi",
"evening": "Tension artérielle le soir"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "le matin",
"midday": "à midi",
"evening": "le soir"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5393,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": {
@@ -5653,7 +5702,11 @@
"recovery": "Récupération",
"stress": "Stress",
"sleepDebt": "Dette de sommeil",
"hrvRmssd": "VFC (RMSSD)"
"hrvRmssd": "VFC (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Niveau de stress",
"trainingReadiness": "Disponibilité à l'entraînement",
"acuteTrainingLoad": "Charge d'entraînement aiguë"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Appareil externe",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Montre",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Dernière saisie ici {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin n'a pas de connexion directe ici. Connectez-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin (Body Battery, Training Load, statut VFC, VO₂ max) ne sont partagés par Garmin avec aucune des deux plateformes.",
"docsLink": "Lire le guide"
},
"glucoseReference": {
"title": "Plage de référence du glucose",
"description": "La plage à laquelle vos glycémies sont comparées.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Vous êtes déconnecté et votre compte est verrouillé immédiatement. Au bout de {days} jours, il est effacé définitivement, avec toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit. D’ici là, un administrateur de ce serveur peut annuler la suppression. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
"deleteAccountScheduled": "Votre compte est verrouillé et sera effacé le {date}. Déconnexion en cours.",
"dangerZoneSuccessGrace": "Votre dossier de santé a été supprimé. Un administrateur de ce serveur peut le restaurer jusqu’au {date}.",
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau."
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Collez une clé Garmin Personal API pour synchroniser le sommeil, la VFC, la Body Battery, le stress, les pas, le poids et les activités depuis Garmin Connect.",
"garminSync": "Synchroniser maintenant",
"garminSyncResult": "{count} entrées synchronisées",
"garminSyncFailed": "Échec de la synchronisation",
"garminViewData": "Voir votre récupération",
"garminReplaceKey": "Remplacer la clé",
"garminDisconnect": "Déconnecter",
"garminDisconnectTitle": "Déconnecter Garmin ?",
"garminDisconnectDescription": "Les synchronisations futures s'arrêtent et la clé est supprimée de ce serveur. Les données déjà importées restent dans votre historique. La clé reste valide chez Garmin jusqu'à ce que vous la révoquiez.",
"garminKeyValidUntil": "Clé valide jusqu'au {date}",
"garminKeyExpired": "Votre clé Garmin a expiré le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour reprendre la synchronisation.",
"garminKeyExpiresToday": "Votre clé Garmin expire aujourd'hui. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour que la synchronisation continue.",
"garminKeyExpiring": "Votre clé Garmin expire le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici avant cette date.",
"garminKeyExpiryUnknown": "Garmin n'indique pas quand cette clé expire. Remplacez la clé et choisissez la validité sélectionnée dans Garmin Connect : cette carte vous préviendra une semaine avant.",
"garminConnected": "Garmin connecté. La première synchronisation est en route.",
"garminKeyHowTo": "Dans Garmin Connect, ouvrez Paramètres, puis Personal API, et créez une clé. Garmin ne l'affiche qu'une fois : copiez-la tout de suite.",
"garminKeySettingsLink": "Ouvrir Garmin Connect",
"garminApiKey": "Clé Personal API",
"garminApiKeyPlaceholder": "Collez la clé",
"garminApiKeySavedPlaceholder": "Enregistrée. Collez une nouvelle clé pour la remplacer",
"garminKeyValidity": "Validité choisie",
"garminKeyValidityUnknown": "Je ne sais pas",
"garminKeyValidityDays": "{count} jours",
"garminKeyValidityHelp": "Garmin n'indique pas quand une clé expire. Avec la validité, cette carte vous prévient une semaine avant que la clé cesse de fonctionner.",
"garminConnect": "Connecter",
"garminUpdate": "Enregistrer les modifications",
"garminRegion": "Service Garmin",
"garminRegionGlobal": "Garmin Connect (international)",
"garminRegionChina": "Garmin Connect Chine",
"garminErrors": {
"keyRefused": "Garmin n'a pas accepté cette clé. Vérifiez qu'elle a été copiée en entier, qu'elle appartient au service choisi et qu'elle n'a ni expiré ni été révoquée.",
"rateLimited": "Garmin limite les requêtes en ce moment. Réessayez dans quelques minutes.",
"unreachable": "Impossible de joindre Garmin. Réessayez dans quelques minutes.",
"invalid": "Cette clé ne semble pas correcte. Collez-la à nouveau, sans espaces.",
"generic": "Cela n'a pas fonctionné. Réessayez."
},
"garminKeyValidityMenu": "Validité de la clé",
"garminValiditySaved": "Validité enregistrée.",
"garminKeyCreatedOn": "Créée le",
"garminKeyCreatedOnHelp": "Le jour où vous avez créé la clé dans Garmin Connect. Laissé vide, le décompte part du jour où vous l'avez connectée ici.",
"garminUnavailable": "Garmin ne fournit pas ces données pour votre compte : {list}.",
"garminResource": {
"daily": "résumés quotidiens",
"activities": "activités",
"sleep": "sommeil",
"hrv": "VFC",
"weight": "pesées",
"cycle": "cycle menstruel"
}
},
"admin": {
"title": "Administration",
@@ -7913,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",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
"extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
"pdfRendererUnavailable": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF : un compte rendu scanné ne peut être lu ici que par un fournisseur d’IA qui accepte directement les fichiers PDF. Importez plutôt une photo.",
"refTextLabel": "Plage telle qu'imprimée",
"refTextPlaceholder": "p. ex. 3,5 - 5,0",
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment."
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment.",
"errorUnitMismatchNoUnit": "Ce marqueur est suivi sans unité. Cochez Sans unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
"unitDiffersBadgeNoUnit": "Suivi sans unité ; ce compte rendu indique {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection."
},
"interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}."
},
"unit": {
"none": "Sans unité",
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
}
},
"modules": {
@@ -10845,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.",
@@ -10858,7 +11048,7 @@
"resolved": "Résolus"
},
"flares": {
"countOne": "1 poussée",
"countOne": "{count} poussée",
"countOther": "{count} poussées"
},
"timeline": {
@@ -10990,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": {
@@ -11100,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": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "Le fichier stocké n’a pas pu être ouvert pour la lecture.",
"raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture. Les formats de compression de fax numérisés en sont une cause connue.",
"provider-error": "Le fournisseur d’IA n’a pas pu lire ce document lors de la dernière tentative.",
"pdf-needs-anthropic": "Le fournisseur d’IA configuré ne peut pas lire les fichiers PDF. Un fournisseur Claude ou l’OCR local le peut.",
"pdf-needs-anthropic": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF, et ce PDF n’a pas de couche texte. Un fournisseur d’IA qui accepte directement les fichiers PDF peut le lire, ou des photos des pages.",
"empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document.",
"resource-limit": "Ce PDF est trop volumineux ou trop complexe pour être lu ici. Le fichier lui-même est intact et s'ouvre toujours."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
"errorRateLimitedWaitOther": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
"errorBudget": "Votre budget IA du jour est épuisé.",
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
"errorPdfRendererUnavailable": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF : un PDF scanné ne peut être lu ici que par un fournisseur d’IA qui accepte directement les fichiers PDF, ou sous forme de photos des pages.",
"errorFileType": "Ce type de fichier ne peut pas être lu.",
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
"errorOutputLimit": "Le fournisseur d’IA a atteint sa limite de sortie avant de renvoyer la moindre valeur. Essayez un modèle qui accepte des réponses plus longues.",
@@ -12057,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",
@@ -12089,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.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13408,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",
@@ -13475,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",
@@ -13671,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"
},
@@ -13704,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": {
@@ -13728,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"
},
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "Si besoin",
"startsOn": "Début",
"endsOn": "Fin"
}
},
"unitlessHint": "Un rapport ou un indice sans unité (INR, cholestérol/HDL) : inscrivez 1 dans la colonne de l'unité. Une unité vide reste une erreur."
}
}
+231 -23
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
"durationTooLong": "Una voce non può superare le 24 ore.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Livello di stress",
"typeTrainingReadiness": "Prontezza all'allenamento",
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Programmata",
"intakeHistoryColStatus": "Stato",
"intakeHistoryColSource": "Sorgente",
"intakeHistoryInjectionSite": "Sito di iniezione: {site}",
"intakeHistoryStatusTaken": "Assunta",
"intakeHistoryStatusSkipped": "Saltata",
"intakeHistorySortByTaken": "Ordina per ora di assunzione",
@@ -4758,7 +4765,8 @@
"unitScore": "punti",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carico"
},
"sleepQuality": {
"title": "Qualità del sonno",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo.",
"bodyBatteryExplainer": "La tua riserva di energia sulla scala da 0 a 100 di Garmin, il livello più alto della giornata. Riposo e sonno la ricaricano, stress e attività la consumano.",
"trainingReadinessExplainer": "Quanto il tuo corpo è pronto per una sessione impegnativa, sulla scala da 0 a 100 di Garmin. Arriva tramite un ponte che hai configurato, perché Garmin non la condivide direttamente.",
"stressLevelExplainer": "Il livello di stress medio della giornata secondo Garmin, su una scala da 0 a 100, ricavato dalla variabilità della frequenza cardiaca. Più basso significa più calmo.",
"acuteTrainingLoadExplainer": "Il carico di allenamento acuto di Garmin: il carico ponderato delle tue attività degli ultimi sette giorni. Arriva tramite un ponte che hai configurato. Confrontalo con le tue settimane recenti."
},
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "In questo periodo non è ancora stato registrato alcun punteggio.",
"usualRange": "In ombra: il tuo intervallo abituale, da {lo} a {hi}.",
"seam": "La linea si interrompe dove è cambiato ciò che conta per il punteggio."
},
"bpTimeOfDay": {
"title": "Per momento della giornata",
"description": "La tua pressione arteriosa media in ogni momento della giornata, nel periodo del grafico.",
"part": {
"morning": "Mattina",
"midday": "Mezzogiorno",
"evening": "Sera"
},
"filterAll": "Tutto il giorno",
"filterLabel": "Mostra nel grafico",
"readingsOne": "{count} misurazione",
"readingsFew": "{count} misurazioni",
"readingsOther": "{count} misurazioni",
"notEnough": "Troppe poche misurazioni",
"pulse": "Pulsazioni {value} bpm",
"difference": "Mattina meno sera: {systolic}/{diastolic} mmHg.",
"differencePending": "Mattina e sera vengono confrontate quando ciascuna ha almeno {min} misurazioni.",
"hours": "Mattina {morning}, mezzogiorno {midday}, sera {evening}, nel tuo fuso orario.",
"hourRange": "{start}–{end}",
"emptyTitle": "Nessuna misurazione completa in questo periodo",
"emptyDescription": "Qui compaiono le misurazioni con valore sistolico e diastolico.",
"error": "Impossibile caricare le medie per momento della giornata.",
"chartTitle": {
"morning": "Pressione arteriosa al mattino",
"midday": "Pressione arteriosa a mezzogiorno",
"evening": "Pressione arteriosa alla sera"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "al mattino",
"midday": "a mezzogiorno",
"evening": "alla sera"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5653,7 +5702,11 @@
"recovery": "Recupero",
"stress": "Stress",
"sleepDebt": "Debito di sonno",
"hrvRmssd": "HRV (RMSSD)"
"hrvRmssd": "HRV (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Livello di stress",
"trainingReadiness": "Prontezza all'allenamento",
"acuteTrainingLoad": "Carico di allenamento acuto"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo esterno",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Orologio",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Ultimo inserimento qui {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin (Body Battery, Training Load, stato HRV, VO₂ max) non vengono condivisi da Garmin con nessuna delle due piattaforme.",
"docsLink": "Leggi la guida"
},
"glucoseReference": {
"title": "Banda di riferimento della glicemia",
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Vieni disconnesso e il tuo account viene bloccato subito. Dopo {days} giorni viene cancellato definitivamente, con tutti i dati sanitari, le passkey, le sessioni e le voci di audit. Fino ad allora un amministratore di questo server può annullare l’eliminazione. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
"deleteAccountScheduled": "Il tuo account è bloccato e verrà cancellato il {date}. Disconnessione in corso.",
"dangerZoneSuccessGrace": "La tua cartella sanitaria è stata eliminata. Un amministratore di questo server può ripristinarla fino al {date}.",
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo."
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Incolla una chiave Garmin Personal API per sincronizzare sonno, HRV, Body Battery, stress, passi, peso e attività da Garmin Connect.",
"garminSync": "Sincronizza ora",
"garminSyncResult": "{count} voci sincronizzate",
"garminSyncFailed": "Sincronizzazione non riuscita",
"garminViewData": "Vedi il tuo recupero",
"garminReplaceKey": "Sostituisci la chiave",
"garminDisconnect": "Disconnetti",
"garminDisconnectTitle": "Disconnettere Garmin?",
"garminDisconnectDescription": "Le sincronizzazioni future si fermano e la chiave viene rimossa da questo server. I dati già importati restano nella tua cronologia. La chiave resta valida su Garmin finché non la revochi lì.",
"garminKeyValidUntil": "Chiave valida fino al {date}",
"garminKeyExpired": "La tua chiave Garmin è scaduta il {date}. Crea una nuova chiave in Garmin Connect e incollala qui per riprendere la sincronizzazione.",
"garminKeyExpiresToday": "La tua chiave Garmin scade oggi. Crea una nuova chiave in Garmin Connect e incollala qui perché la sincronizzazione non si fermi.",
"garminKeyExpiring": "La tua chiave Garmin scade il {date}. Crea prima una nuova chiave in Garmin Connect e incollala qui.",
"garminKeyExpiryUnknown": "Garmin non dice quando scade questa chiave. Sostituisci la chiave e scegli la validità impostata in Garmin Connect: questa scheda ti avviserà una settimana prima.",
"garminConnected": "Garmin collegato. La prima sincronizzazione è in arrivo.",
"garminKeyHowTo": "In Garmin Connect apri Impostazioni, poi Personal API, e crea una chiave. Garmin la mostra una sola volta, quindi copiala subito.",
"garminKeySettingsLink": "Apri Garmin Connect",
"garminApiKey": "Chiave Personal API",
"garminApiKeyPlaceholder": "Incolla la chiave",
"garminApiKeySavedPlaceholder": "Salvata. Incolla una nuova chiave per sostituirla",
"garminKeyValidity": "Validità scelta",
"garminKeyValidityUnknown": "Non lo so",
"garminKeyValidityDays": "{count} giorni",
"garminKeyValidityHelp": "Garmin non segnala quando una chiave scade. Con la validità, questa scheda ti avvisa una settimana prima che la chiave smetta di funzionare.",
"garminConnect": "Collega",
"garminUpdate": "Salva modifiche",
"garminRegion": "Servizio Garmin",
"garminRegionGlobal": "Garmin Connect (globale)",
"garminRegionChina": "Garmin Connect Cina",
"garminErrors": {
"keyRefused": "Garmin non ha accettato questa chiave. Controlla che sia stata copiata per intero, che appartenga al servizio scelto e che non sia scaduta o revocata.",
"rateLimited": "Garmin sta limitando le richieste. Riprova tra qualche minuto.",
"unreachable": "Garmin non è raggiungibile. Riprova tra qualche minuto.",
"invalid": "Questa chiave non sembra corretta. Incollala di nuovo, senza spazi.",
"generic": "Non ha funzionato. Riprova."
},
"garminKeyValidityMenu": "Validità della chiave",
"garminValiditySaved": "Validità salvata.",
"garminKeyCreatedOn": "Creata il",
"garminKeyCreatedOnHelp": "Il giorno in cui hai creato la chiave in Garmin Connect. Se lo lasci vuoto, si conta dal giorno in cui l'hai collegata qui.",
"garminUnavailable": "Garmin non fornisce questi dati per il tuo account: {list}.",
"garminResource": {
"daily": "riepiloghi giornalieri",
"activities": "attività",
"sleep": "sonno",
"hrv": "HRV",
"weight": "pesate",
"cycle": "ciclo mestruale"
}
},
"admin": {
"title": "Administration",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "Il file è troppo grande (max 12 MB).",
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
"extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
"pdfRendererUnavailable": "Il processore di questo server non può eseguire il rendering delle pagine PDF, quindi un referto scansionato può essere letto solo da un provider IA che accetta direttamente i file PDF. Carica invece una foto.",
"refTextLabel": "Intervallo come stampato",
"refTextPlaceholder": "es. 3,5 - 5,0",
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento."
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento.",
"errorUnitMismatchNoUnit": "Questo marcatore è registrato senza unità. Spunta Nessuna unità se il referto è stato letto male, oppure deseleziona questo valore.",
"unitDiffersBadgeNoUnit": "Registrato senza unità; questo referto indica {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione."
},
"interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}."
},
"unit": {
"none": "Nessuna unità",
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "Il file archiviato non può essere aperto per la lettura.",
"raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura. I formati di compressione fax digitalizzati sono una causa nota.",
"provider-error": "Il provider IA non è riuscito a leggere questo documento all’ultimo tentativo.",
"pdf-needs-anthropic": "Il provider IA configurato non può leggere i file PDF. Un provider Claude o l’OCR locale sì.",
"pdf-needs-anthropic": "Il processore di questo server non può eseguire il rendering delle pagine PDF e questo PDF non ha un livello di testo. Può leggerlo un provider IA che accetta direttamente i file PDF, oppure carica le foto delle pagine.",
"empty-transcription": "Il provider IA non ha restituito testo per questo documento.",
"resource-limit": "Questo PDF è troppo grande o complesso per essere letto qui. Il file in sé è a posto e si apre ancora."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
"errorRateLimitedWaitOther": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
"errorPdfRendererUnavailable": "Il processore di questo server non può eseguire il rendering delle pagine PDF, quindi un PDF scansionato può essere letto solo da un provider IA che accetta direttamente i file PDF, oppure come foto delle pagine.",
"errorFileType": "Questo tipo di file non può essere letto.",
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
"errorOutputLimit": "Il fornitore di IA ha raggiunto il suo limite di output prima di restituire un solo valore. Prova con un modello che consente risposte più lunghe.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "Al bisogno",
"startsOn": "Inizio",
"endsOn": "Fine"
}
},
"unitlessHint": "Un rapporto o un indice senza unità (INR, colesterolo/HDL): scrivi 1 nella colonna dell'unità. Un'unità vuota resta un errore."
}
}
+231 -23
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "심박변이도",
"typeRestingHeartRate": "안정 시 심박수",
"typeActiveEnergyBurned": "활동 에너지",
"typeBasalEnergyBurned": "휴식 에너지",
"typeFlightsClimbed": "오른 층수",
"typeWalkingRunningDistance": "걷기 + 달리기 거리",
"typeVo2Max": "VO₂ max",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes}분",
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "바디 배터리",
"typeStressLevel": "스트레스 수준",
"typeTrainingReadiness": "트레이닝 준비도",
"typeAcuteTrainingLoad": "급성 훈련 부하",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "HealthLog가 기록할 수 있는 것",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "예정",
"intakeHistoryColStatus": "상태",
"intakeHistoryColSource": "출처",
"intakeHistoryInjectionSite": "주사 부위: {site}",
"intakeHistoryStatusTaken": "복용함",
"intakeHistoryStatusSkipped": "건너뜀",
"intakeHistorySortByTaken": "복용 시각순 정렬",
@@ -4758,7 +4765,8 @@
"unitScore": "점",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "부하"
},
"sleepQuality": {
"title": "수면 품질",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "쌓인 급성 심혈관 부하를 따라가는 훈련 부하 지표예요. 내 최근 며칠과 비교해 보세요.",
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄).",
"bodyBatteryExplainer": "Garmin의 0~100 척도로 본 에너지 비축량으로, 하루 중 가장 높았던 값입니다. 휴식과 수면은 채우고, 스트레스와 활동은 소모합니다.",
"trainingReadinessExplainer": "Garmin의 0~100 척도로 본 고강도 운동에 대한 준비 정도입니다. Garmin이 직접 공유하지 않으므로 직접 설정한 브리지를 통해 들어옵니다.",
"stressLevelExplainer": "심박변이도에서 읽어낸 Garmin의 하루 평균 스트레스 수준(0~100)입니다. 낮을수록 차분한 상태입니다.",
"acuteTrainingLoadExplainer": "Garmin의 급성 훈련 부하로, 최근 7일 동안의 활동 부하를 가중 합산한 값입니다. 직접 설정한 브리지를 통해 들어옵니다. 최근 몇 주의 내 기록과 비교해 보세요."
},
"loadError": "회복 값을 불러오지 못했어요."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.",
"usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.",
"seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다."
},
"bpTimeOfDay": {
"title": "시간대별",
"description": "차트 기간 동안 하루 중 시간대별 평균 혈압입니다.",
"part": {
"morning": "아침",
"midday": "낮",
"evening": "저녁"
},
"filterAll": "하루 전체",
"filterLabel": "차트에 표시",
"readingsOne": "측정 {count}회",
"readingsFew": "측정 {count}회",
"readingsOther": "측정 {count}회",
"notEnough": "측정 횟수 부족",
"pulse": "맥박 {value} bpm",
"difference": "아침 빼기 저녁: {systolic}/{diastolic} mmHg.",
"differencePending": "아침과 저녁에 각각 측정이 {min}회 이상 있으면 비교합니다.",
"hours": "아침 {morning}, 낮 {midday}, 저녁 {evening}, 내 시간대 기준.",
"hourRange": "{start}–{end}",
"emptyTitle": "이 기간에 완전한 측정이 없습니다",
"emptyDescription": "수축기와 이완기 값이 모두 있는 측정이 여기에 표시됩니다.",
"error": "시간대별 평균을 불러오지 못했습니다.",
"chartTitle": {
"morning": "아침 혈압",
"midday": "낮 혈압",
"evening": "저녁 혈압"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "아침",
"midday": "낮",
"evening": "저녁"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,7 +5286,8 @@
},
"hints": {
"apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.",
"file": "여기서 마친 뒤 설정에서 가져옵니다."
"file": "여기서 마친 뒤 설정에서 가져옵니다.",
"fitbit": "이제 Google Health를 통해 연결됩니다."
}
},
"visit": {
@@ -5293,6 +5341,7 @@
"waiting": "아직 연결되지 않았습니다.",
"openSettings": "설정 → 연동 열기",
"noDate": "알 수 없음",
"throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.",
"needsCredentials": {
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
@@ -5653,7 +5702,11 @@
"recovery": "회복",
"stress": "스트레스",
"sleepDebt": "수면 부채",
"hrvRmssd": "심박변이도 (RMSSD)"
"hrvRmssd": "심박변이도 (RMSSD)",
"bodyBattery": "바디 배터리",
"stressLevel": "스트레스 수준",
"trainingReadiness": "트레이닝 준비도",
"acuteTrainingLoad": "급성 훈련 부하"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "외부 기기",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "워치",
@@ -6853,7 +6907,9 @@
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동",
"medicationDoses": "약 복용"
"medicationDoses": "약 복용",
"cycleDays": "주기 기록일",
"cycleDaysCount": "주기 기록일 ({count})"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "여기서는 Garmin에 직접 연결할 수 없어요. Apple Health(iOS)나 Google Health Connect(Android)를 거쳐 연결하세요. Body Battery, Training Load, HRV Status, VO₂ max 같은 Garmin 고유 점수는 Garmin이 두 플랫폼 어디에도 공유하지 않아요.",
"docsLink": "가이드 보기"
},
"glucoseReference": {
"title": "혈당 참고 구간",
"description": "혈당 측정값을 어떤 구간에 견줘 판단할지 정해요.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "바로 로그아웃되고 계정이 잠겨요. {days}일 뒤에는 모든 건강 데이터, 패스키, 세션, 감사 기록과 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
"deleteAccountScheduled": "계정이 잠겼고 {date}에 완전히 지워져요. 로그아웃하는 중이에요.",
"dangerZoneSuccessGrace": "건강 기록이 삭제됐어요. {date}까지는 이 서버의 관리자가 복원할 수 있어요.",
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요."
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Garmin Personal API 키를 붙여 넣으면 Garmin Connect의 수면, HRV, 바디 배터리, 스트레스, 걸음 수, 체중, 활동이 동기화됩니다.",
"garminSync": "지금 동기화",
"garminSyncResult": "{count}개 항목 동기화됨",
"garminSyncFailed": "동기화 실패",
"garminViewData": "회복 보기",
"garminReplaceKey": "키 교체",
"garminDisconnect": "연결 해제",
"garminDisconnectTitle": "Garmin 연결을 해제할까요?",
"garminDisconnectDescription": "앞으로의 동기화가 중지되고 키가 이 서버에서 삭제됩니다. 이미 가져온 데이터는 기록에 남습니다. 키는 Garmin에서 취소할 때까지 계속 유효합니다.",
"garminKeyValidUntil": "키 유효 기간: {date}까지",
"garminKeyExpired": "Garmin 키가 {date}에 만료되었습니다. Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으면 동기화가 다시 시작됩니다.",
"garminKeyExpiresToday": "Garmin 키가 오늘 만료됩니다. 동기화가 멈추지 않도록 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
"garminKeyExpiring": "Garmin 키가 {date}에 만료됩니다. 그 전에 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
"garminKeyExpiryUnknown": "Garmin은 이 키의 만료일을 알려 주지 않습니다. 키를 교체하면서 Garmin Connect에서 고른 유효 기간을 선택하면 이 카드가 일주일 전에 알려 드립니다.",
"garminConnected": "Garmin이 연결되었습니다. 첫 동기화가 시작됩니다.",
"garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.",
"garminKeySettingsLink": "Garmin Connect 열기",
"garminApiKey": "Personal API 키",
"garminApiKeyPlaceholder": "키 붙여 넣기",
"garminApiKeySavedPlaceholder": "저장됨. 교체하려면 새 키를 붙여 넣으세요",
"garminKeyValidity": "선택한 유효 기간",
"garminKeyValidityUnknown": "모르겠음",
"garminKeyValidityDays": "{count}일",
"garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.",
"garminConnect": "연결",
"garminUpdate": "변경 사항 저장",
"garminRegion": "Garmin 서비스",
"garminRegionGlobal": "Garmin Connect (글로벌)",
"garminRegionChina": "Garmin Connect 중국",
"garminErrors": {
"keyRefused": "Garmin이 이 키를 받아들이지 않았습니다. 키를 빠짐없이 복사했는지, 선택한 서비스의 키인지, 만료되거나 취소되지 않았는지 확인하세요.",
"rateLimited": "Garmin이 지금 요청을 제한하고 있습니다. 몇 분 후에 다시 시도하세요.",
"unreachable": "Garmin에 연결할 수 없습니다. 몇 분 후에 다시 시도하세요.",
"invalid": "키 형식이 올바르지 않습니다. 공백 없이 다시 붙여 넣으세요.",
"generic": "실패했습니다. 다시 시도하세요."
},
"garminKeyValidityMenu": "키 유효 기간",
"garminValiditySaved": "유효 기간을 저장했습니다.",
"garminKeyCreatedOn": "생성일",
"garminKeyCreatedOnHelp": "Garmin Connect에서 키를 만든 날입니다. 비워 두면 여기에 연결한 날부터 계산합니다.",
"garminUnavailable": "Garmin이 내 계정에 이 데이터를 제공하지 않습니다: {list}.",
"garminResource": {
"daily": "일일 요약",
"activities": "활동",
"sleep": "수면",
"hrv": "HRV",
"weight": "체중 측정",
"cycle": "생리 주기"
}
},
"admin": {
"title": "관리자",
@@ -9924,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": "생리가 곧 시작될 것 같아요",
@@ -10220,6 +10385,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "파일이 너무 커요 (최대 12MB).",
"fileType": "JPEG, PNG, WebP 또는 PDF를 올려 주세요.",
"extractFailed": "결과지를 읽지 못했어요. 더 선명한 사진으로 시도해 주세요.",
"pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.",
"pdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 검사지는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있어요. 대신 사진을 올려 주세요.",
"refTextLabel": "적힌 그대로의 범위",
"refTextPlaceholder": "예: 3.5 - 5.0",
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다."
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.",
"errorUnitMismatchNoUnit": "이 지표는 단위 없이 기록됩니다. 결과지를 잘못 읽었다면 단위 없음을 선택하거나 이 값의 선택을 해제하세요.",
"unitDiffersBadgeNoUnit": "단위 없이 기록 중; 이 결과지는 {unit}"
},
"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}",
@@ -10447,6 +10632,7 @@
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
@@ -10474,6 +10660,10 @@
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
},
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
},
"unit": {
"none": "단위 없음",
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
}
},
"modules": {
@@ -11903,7 +12093,8 @@
"unsupportedType": "지원하지 않는 파일 형식이에요.",
"purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.",
"duplicateExists": "같은 문서가 이미 저장돼 있어요.",
"rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.",
"uploadBusy": "서버가 다른 업로드를 처리하느라 바빴어요. 지금 다시 시도해 주세요.",
"rateLimited": "이번 시간의 업로드 한도에 도달했어요. 나중에 다시 시도해 주세요.",
"generic": "업로드하지 못했어요. 다시 시도해 주세요."
},
"toast": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "저장된 파일을 열어 읽지 못했어요.",
"raster-failed": "PDF 페이지를 읽기용으로 렌더링하지 못했어요. 스캔 팩스 압축 형식이 흔한 원인이에요.",
"provider-error": "지난번에 AI 제공자가 이 문서를 읽지 못했어요.",
"pdf-needs-anthropic": "설정된 AI 제공자는 PDF 파일을 읽을 수 없어요. Claude 제공자나 로컬 OCR로는 읽을 수 있어요.",
"pdf-needs-anthropic": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없고, 이 PDF에는 텍스트 레이어가 없어요. PDF 파일을 직접 받는 AI 제공자는 읽을 수 있고, 페이지 사진을 올려도 돼요.",
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요.",
"resource-limit": "이 PDF는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
"errorRateLimitedWaitOther": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
"errorBudget": "오늘 쓸 수 있는 AI 예산을 다 썼어요.",
"errorPdfNeedsVision": "PDF를 읽으려면 Claude 비전 제공자가 필요해요.",
"errorPdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 PDF는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있고, 페이지를 사진으로 올려도 돼요.",
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
@@ -13054,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": {
@@ -13231,7 +13426,9 @@
"specialty": "{date} 마지막 {specialty} 방문 이후",
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
"default": "최근 {days}일",
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
"capped": "최근 2년",
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
}
},
"checkups": {
@@ -13747,7 +13944,17 @@
},
"legendLabValue": "측정된 그대로의 검사 수치",
"legendLabOutside": "참고 범위 밖",
"legendLabRange": "참고 범위"
"legendLabRange": "참고 범위",
"legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값",
"symptoms": {
"menuTitle": "증상",
"full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.",
"hidden": "숨김",
"unnamed": "증상",
"daysOne": "1일",
"daysFew": "{count}일 평균",
"daysOther": "{count}일 평균"
}
},
"lifeEvents": {
"title": "삶의 사건",
@@ -14093,6 +14300,7 @@
"asNeeded": "필요시",
"startsOn": "시작",
"endsOn": "종료"
}
},
"unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요."
}
}
+231 -23
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Hartslagvariabiliteit",
"typeRestingHeartRate": "Rusthartslag",
"typeActiveEnergyBurned": "Actieve energie",
"typeBasalEnergyBurned": "Rustenergie",
"typeFlightsClimbed": "Beklommen verdiepingen",
"typeWalkingRunningDistance": "Afstand wandelen + hardlopen",
"typeVo2Max": "VO₂max",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Voer een duur in, bijvoorbeeld 7,5, 7:30 of 7h 30m.",
"durationTooLong": "Eén registratie kan niet langer zijn dan 24 uur.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Stressniveau",
"typeTrainingReadiness": "Trainingsgereedheid",
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Wat HealthLog kan bijhouden",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Gepland",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Bron",
"intakeHistoryInjectionSite": "Injectieplaats: {site}",
"intakeHistoryStatusTaken": "Ingenomen",
"intakeHistoryStatusSkipped": "Overgeslagen",
"intakeHistorySortByTaken": "Sorteren op innametijd",
@@ -4758,7 +4765,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "belasting"
},
"sleepQuality": {
"title": "Slaapkwaliteit",
@@ -4777,7 +4785,11 @@
"cardioLoadExplainer": "Een indicatie van trainingsbelasting die de acute cardiovasculaire belasting volgt die je hebt opgebouwd. Vergelijk het met je eigen recente dagen.",
"averageHeartRateExplainer": "Je gemiddelde hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
"maxHeartRateExplainer": "Je hoogste hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert."
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert.",
"bodyBatteryExplainer": "Je energiereserve op de schaal van 0 tot 100 van Garmin, het hoogste niveau van de dag. Rust en slaap vullen hem aan, stress en activiteit putten hem uit.",
"trainingReadinessExplainer": "Hoe klaar je lichaam is voor een zware training, op de schaal van 0 tot 100 van Garmin. De waarde komt via een brug die je zelf hebt ingesteld, omdat Garmin hem niet rechtstreeks deelt.",
"stressLevelExplainer": "Garmins gemiddelde stressniveau over de dag op een schaal van 0 tot 100, afgeleid uit je hartslagvariabiliteit. Lager is rustiger.",
"acuteTrainingLoadExplainer": "Garmins acute trainingsbelasting: de gewogen belasting van je activiteiten van de afgelopen zeven dagen. De waarde komt via een brug die je zelf hebt ingesteld. Vergelijk hem met je eigen recente weken."
},
"emptyAction": "Integraties openen",
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
@@ -4971,6 +4983,40 @@
"emptyDescription": "In deze periode is nog geen score vastgelegd.",
"usualRange": "Gearceerd: je gebruikelijke bereik, {lo} tot {hi}.",
"seam": "De lijn wordt onderbroken op het moment dat veranderde wat meetelt voor de score."
},
"bpTimeOfDay": {
"title": "Per moment van de dag",
"description": "Je gemiddelde bloeddruk per moment van de dag, over de periode van de grafiek.",
"part": {
"morning": "Ochtend",
"midday": "Middag",
"evening": "Avond"
},
"filterAll": "Hele dag",
"filterLabel": "Tonen in de grafiek",
"readingsOne": "{count} meting",
"readingsFew": "{count} metingen",
"readingsOther": "{count} metingen",
"notEnough": "Te weinig metingen",
"pulse": "Hartslag {value} bpm",
"difference": "Ochtend min avond: {systolic}/{diastolic} mmHg.",
"differencePending": "Ochtend en avond worden vergeleken zodra elk minstens {min} metingen heeft.",
"hours": "Ochtend {morning}, middag {midday}, avond {evening}, in je eigen tijdzone.",
"hourRange": "{start}–{end}",
"emptyTitle": "Geen volledige metingen in deze periode",
"emptyDescription": "Metingen met zowel een systolische als een diastolische waarde verschijnen hier.",
"error": "De gemiddelden per moment van de dag konden niet worden geladen.",
"chartTitle": {
"morning": "Bloeddruk 's ochtends",
"midday": "Bloeddruk 's middags",
"evening": "Bloeddruk 's avonds"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "'s ochtends",
"midday": "'s middags",
"evening": "'s avonds"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5653,7 +5702,11 @@
"recovery": "Herstel",
"stress": "Stress",
"sleepDebt": "Slaaptekort",
"hrvRmssd": "HRV (RMSSD)"
"hrvRmssd": "HRV (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Stressniveau",
"trainingReadiness": "Trainingsgereedheid",
"acuteTrainingLoad": "Acute trainingsbelasting"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Extern apparaat",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Horloge",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Laatst hier ingevoerd {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin heeft hier geen directe koppeling. Koppel via Apple Health (iOS) of Google Health Connect (Android). De eigen scores van Garmin (Body Battery, Training Load, HRV Status, VO₂ max) deelt Garmin met geen van beide platforms.",
"docsLink": "Lees de handleiding"
},
"glucoseReference": {
"title": "Referentiebereik voor glucose",
"description": "Aan welk bereik je bloedglucosemetingen worden getoetst.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Je wordt uitgelogd en je account wordt meteen vergrendeld. Na {days} dagen wordt het definitief gewist, met alle gezondheidsgegevens, passkeys, sessies en auditregels. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
"deleteAccountScheduled": "Je account is vergrendeld en wordt op {date} gewist. Je wordt uitgelogd.",
"dangerZoneSuccessGrace": "Je gezondheidsdossier is verwijderd. Een beheerder van deze server kan het tot {date} terugzetten.",
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld."
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Plak een Garmin Personal API-sleutel om slaap, HRV, Body Battery, stress, stappen, gewicht en activiteiten uit Garmin Connect te synchroniseren.",
"garminSync": "Nu synchroniseren",
"garminSyncResult": "{count} items gesynchroniseerd",
"garminSyncFailed": "Synchronisatie mislukt",
"garminViewData": "Je herstel bekijken",
"garminReplaceKey": "Sleutel vervangen",
"garminDisconnect": "Ontkoppelen",
"garminDisconnectTitle": "Garmin ontkoppelen?",
"garminDisconnectDescription": "Toekomstige synchronisaties stoppen en de sleutel wordt van deze server verwijderd. Al geïmporteerde gegevens blijven in je geschiedenis. Bij Garmin blijft de sleutel werken tot je hem daar intrekt.",
"garminKeyValidUntil": "Sleutel geldig tot {date}",
"garminKeyExpired": "Je Garmin-sleutel is op {date} verlopen. Maak in Garmin Connect een nieuwe sleutel en plak die hier om weer te synchroniseren.",
"garminKeyExpiresToday": "Je Garmin-sleutel verloopt vandaag. Maak in Garmin Connect een nieuwe sleutel en plak die hier zodat het synchroniseren niet stopt.",
"garminKeyExpiring": "Je Garmin-sleutel verloopt op {date}. Maak vóór die datum in Garmin Connect een nieuwe sleutel en plak die hier.",
"garminKeyExpiryUnknown": "Garmin vertelt niet wanneer deze sleutel verloopt. Vervang de sleutel en kies de geldigheid die je in Garmin Connect hebt gekozen, dan waarschuwt deze kaart je een week van tevoren.",
"garminConnected": "Garmin gekoppeld. De eerste synchronisatie is onderweg.",
"garminKeyHowTo": "Open in Garmin Connect de instellingen, dan Personal API, en maak een sleutel. Garmin toont hem maar één keer, dus kopieer hem meteen.",
"garminKeySettingsLink": "Garmin Connect openen",
"garminApiKey": "Personal API-sleutel",
"garminApiKeyPlaceholder": "Plak de sleutel",
"garminApiKeySavedPlaceholder": "Opgeslagen. Plak een nieuwe sleutel om hem te vervangen",
"garminKeyValidity": "Gekozen geldigheid",
"garminKeyValidityUnknown": "Weet ik niet",
"garminKeyValidityDays": "{count} dagen",
"garminKeyValidityHelp": "Garmin meldt niet wanneer een sleutel verloopt. Met de geldigheid waarschuwt deze kaart je een week voordat de sleutel stopt met werken.",
"garminConnect": "Koppelen",
"garminUpdate": "Wijzigingen opslaan",
"garminRegion": "Garmin-dienst",
"garminRegionGlobal": "Garmin Connect (wereldwijd)",
"garminRegionChina": "Garmin Connect China",
"garminErrors": {
"keyRefused": "Garmin heeft deze sleutel niet geaccepteerd. Controleer of hij volledig is gekopieerd, bij de gekozen dienst hoort en niet verlopen of ingetrokken is.",
"rateLimited": "Garmin beperkt op dit moment het aantal verzoeken. Probeer het over een paar minuten opnieuw.",
"unreachable": "Garmin is niet bereikbaar. Probeer het over een paar minuten opnieuw.",
"invalid": "Die sleutel ziet er niet goed uit. Plak hem opnieuw, zonder spaties.",
"generic": "Dat is niet gelukt. Probeer het opnieuw."
},
"garminKeyValidityMenu": "Geldigheid van de sleutel",
"garminValiditySaved": "Geldigheid opgeslagen.",
"garminKeyCreatedOn": "Aangemaakt op",
"garminKeyCreatedOnHelp": "De dag waarop je de sleutel in Garmin Connect hebt gemaakt. Leeg gelaten telt de geldigheid vanaf de dag dat je hem hier hebt gekoppeld.",
"garminUnavailable": "Garmin levert deze gegevens niet voor je account: {list}.",
"garminResource": {
"daily": "dagoverzichten",
"activities": "activiteiten",
"sleep": "slaap",
"hrv": "HRV",
"weight": "wegingen",
"cycle": "menstruatiecyclus"
}
},
"admin": {
"title": "Beheer",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "Het bestand is te groot (max. 12 MB).",
"fileType": "Upload een JPEG, PNG, WebP of PDF.",
"extractFailed": "Het rapport kon niet worden gelezen. Probeer een scherpere foto.",
"pdfNeedsAnthropic": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig; upload in plaats daarvan een foto.",
"pdfRendererUnavailable": "De processor van deze server kan geen PDF-pagina's weergeven, dus een gescand rapport kan hier alleen worden gelezen door een AI-provider die PDF-bestanden rechtstreeks aanneemt. Upload in plaats daarvan een foto.",
"refTextLabel": "Bereik zoals afgedrukt",
"refTextPlaceholder": "bijv. 3,5 - 5,0",
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar."
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar.",
"errorUnitMismatchNoUnit": "Deze marker wordt zonder eenheid bijgehouden. Vink Geen eenheid aan als het rapport verkeerd is gelezen, of deselecteer deze meting.",
"unitDiffersBadgeNoUnit": "Zonder eenheid bijgehouden; dit rapport vermeldt {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie."
},
"interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}."
},
"unit": {
"none": "Geen eenheid",
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "Het opgeslagen bestand kon niet worden geopend om te lezen.",
"raster-failed": "De PDF-pagina's konden niet worden weergegeven om te lezen. Compressieformaten van gescande faxen zijn een bekende oorzaak.",
"provider-error": "De AI-provider kon dit document bij de laatste poging niet lezen.",
"pdf-needs-anthropic": "De ingestelde AI-provider kan geen PDF-bestanden lezen. Een Claude-provider of lokale OCR kan dat wel.",
"pdf-needs-anthropic": "De processor van deze server kan geen PDF-pagina's weergeven en deze PDF heeft geen tekstlaag. Een AI-provider die PDF-bestanden rechtstreeks aanneemt kan hem lezen, of upload foto's van de pagina's.",
"empty-transcription": "De AI-provider gaf geen tekst terug voor dit document.",
"resource-limit": "Deze pdf is te groot of te complex om hier te lezen. Het bestand zelf is in orde en opent nog steeds."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "De limiet voor lezen met AI per uur is bereikt. Probeer het over ongeveer {count} minuten opnieuw.",
"errorRateLimitedWaitOther": "De limiet voor lezen met AI per uur is bereikt. Probeer het over ongeveer {count} minuten opnieuw.",
"errorBudget": "Je AI-budget voor vandaag is op.",
"errorPdfNeedsVision": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig.",
"errorPdfRendererUnavailable": "De processor van deze server kan geen PDF-pagina's weergeven, dus een gescande PDF kan hier alleen worden gelezen door een AI-provider die PDF-bestanden rechtstreeks aanneemt, of als foto's van de pagina's.",
"errorFileType": "Dit bestandstype kan niet worden gelezen.",
"errorNotIndexed": "Laat het document eerst lezen en extraheer daarna.",
"errorOutputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een model dat langere antwoorden toestaat.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "Zo nodig",
"startsOn": "Begin",
"endsOn": "Einde"
}
},
"unitlessHint": "Een verhouding of index zonder eenheid (INR, cholesterol/HDL): vul 1 in de eenheidskolom in. Een lege eenheid blijft een fout."
}
}
+231 -23
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Poziom stresu",
"typeTrainingReadiness": "Gotowość do treningu",
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Zaplanowany",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Źródło",
"intakeHistoryInjectionSite": "Miejsce wstrzyknięcia: {site}",
"intakeHistoryStatusTaken": "Przyjęty",
"intakeHistoryStatusSkipped": "Pominięty",
"intakeHistorySortByTaken": "Sortuj wg czasu przyjęcia",
@@ -4758,7 +4765,8 @@
"unitScore": "pkt",
"unitMinutes": "min",
"unitBpm": "ud./min",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "obciążenie"
},
"sleepQuality": {
"title": "Jakość snu",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia.",
"bodyBatteryExplainer": "Twoja rezerwa energii w skali Garmina od 0 do 100, najwyższy poziom dnia. Odpoczynek i sen ją uzupełniają, stres i aktywność ją zużywają.",
"trainingReadinessExplainer": "Jak bardzo Twoje ciało jest gotowe na ciężki trening, w skali Garmina od 0 do 100. Wartość przychodzi przez skonfigurowany przez Ciebie most, bo Garmin nie udostępnia jej bezpośrednio.",
"stressLevelExplainer": "Średni całodzienny poziom stresu według Garmina w skali od 0 do 100, wyliczany ze zmienności rytmu serca. Niżej znaczy spokojniej.",
"acuteTrainingLoadExplainer": "Ostre obciążenie treningowe Garmina: ważone obciążenie z Twoich aktywności z ostatnich siedmiu dni. Wartość przychodzi przez skonfigurowany przez Ciebie most. Porównuj je ze swoimi ostatnimi tygodniami."
},
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
},
@@ -4971,6 +4983,40 @@
"emptyDescription": "W tym okresie nie zapisano jeszcze żadnego wyniku.",
"usualRange": "Zacieniowane: twój zwykły zakres, od {lo} do {hi}.",
"seam": "Linia przerywa się tam, gdzie zmieniło się to, co liczy się do wyniku."
},
"bpTimeOfDay": {
"title": "Według pory dnia",
"description": "Twoje średnie ciśnienie w każdej porze dnia, w zakresie wykresu.",
"part": {
"morning": "Rano",
"midday": "W południe",
"evening": "Wieczorem"
},
"filterAll": "Cały dzień",
"filterLabel": "Pokaż na wykresie",
"readingsOne": "{count} pomiar",
"readingsFew": "{count} pomiary",
"readingsOther": "{count} pomiarów",
"notEnough": "Za mało pomiarów",
"pulse": "Tętno {value} bpm",
"difference": "Rano minus wieczór: {systolic}/{diastolic} mmHg.",
"differencePending": "Rano i wieczór zostaną porównane, gdy każda z tych pór będzie mieć co najmniej {min} pomiary.",
"hours": "Rano {morning}, w południe {midday}, wieczorem {evening}, w Twojej strefie czasowej.",
"hourRange": "{start}–{end}",
"emptyTitle": "Brak pełnych pomiarów w tym zakresie",
"emptyDescription": "Tutaj pojawią się pomiary z wartością skurczową i rozkurczową.",
"error": "Nie udało się wczytać średnich według pory dnia.",
"chartTitle": {
"morning": "Ciśnienie tętnicze rano",
"midday": "Ciśnienie tętnicze w południe",
"evening": "Ciśnienie tętnicze wieczorem"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "rano",
"midday": "w południe",
"evening": "wieczorem"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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ć."
@@ -5653,7 +5702,11 @@
"recovery": "Regeneracja",
"stress": "Stres",
"sleepDebt": "Dług snu",
"hrvRmssd": "HRV (RMSSD)"
"hrvRmssd": "HRV (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Poziom stresu",
"trainingReadiness": "Gotowość do treningu",
"acuteTrainingLoad": "Ostre obciążenie treningowe"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Urządzenie zewnętrzne",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Zegarek",
@@ -6853,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ń."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina (Body Battery, Training Load, status HRV, VO₂ max) nie są udostępniane przez Garmina żadnej z tych platform.",
"docsLink": "Przeczytaj przewodnik"
},
"glucoseReference": {
"title": "Zakres referencyjny glukozy",
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Zostaniesz wylogowany, a twoje konto od razu zablokowane. Po {days} dniach zostanie trwale usunięte, razem ze wszystkimi danymi zdrowotnymi, kluczami dostępu, sesjami i wpisami audytu. Do tego czasu administrator tego serwera może cofnąć usunięcie. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
"deleteAccountScheduled": "Twoje konto jest zablokowane i zostanie usunięte {date}. Trwa wylogowywanie.",
"dangerZoneSuccessGrace": "Twoja dokumentacja zdrowotna została usunięta. Administrator tego serwera może ją przywrócić do {date}.",
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa."
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Wklej klucz Garmin Personal API, aby synchronizować sen, HRV, Body Battery, stres, kroki, wagę i aktywności z Garmin Connect.",
"garminSync": "Synchronizuj teraz",
"garminSyncResult": "Zsynchronizowano wpisy: {count}",
"garminSyncFailed": "Synchronizacja nie powiodła się",
"garminViewData": "Zobacz regenerację",
"garminReplaceKey": "Zastąp klucz",
"garminDisconnect": "Odłącz",
"garminDisconnectTitle": "Odłączyć Garmin?",
"garminDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane, a klucz usunięty z tego serwera. Zaimportowane dane zostają w Twojej historii. Klucz działa w Garminie, dopóki go tam nie unieważnisz.",
"garminKeyValidUntil": "Klucz ważny do {date}",
"garminKeyExpired": "Twój klucz Garmin wygasł {date}. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby wznowić synchronizację.",
"garminKeyExpiresToday": "Twój klucz Garmin wygasa dzisiaj. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby synchronizacja się nie zatrzymała.",
"garminKeyExpiring": "Twój klucz Garmin wygasa {date}. Przed tym dniem utwórz nowy klucz w Garmin Connect i wklej go tutaj.",
"garminKeyExpiryUnknown": "Garmin nie podaje, kiedy ten klucz wygaśnie. Zastąp klucz i wybierz ważność ustawioną w Garmin Connect, a ta karta ostrzeże Cię tydzień wcześniej.",
"garminConnected": "Połączono z Garmin. Pierwsza synchronizacja jest w drodze.",
"garminKeyHowTo": "W Garmin Connect otwórz Ustawienia, następnie Personal API, i utwórz klucz. Garmin pokazuje go tylko raz, więc od razu go skopiuj.",
"garminKeySettingsLink": "Otwórz Garmin Connect",
"garminApiKey": "Klucz Personal API",
"garminApiKeyPlaceholder": "Wklej klucz",
"garminApiKeySavedPlaceholder": "Zapisano. Wklej nowy klucz, aby go zastąpić",
"garminKeyValidity": "Wybrana ważność",
"garminKeyValidityUnknown": "Nie wiem",
"garminKeyValidityDays": "{count} dni",
"garminKeyValidityHelp": "Garmin nie informuje, kiedy klucz wygasa. Dzięki ważności ta karta ostrzeże Cię tydzień przed tym, jak klucz przestanie działać.",
"garminConnect": "Połącz",
"garminUpdate": "Zapisz zmiany",
"garminRegion": "Usługa Garmin",
"garminRegionGlobal": "Garmin Connect (globalny)",
"garminRegionChina": "Garmin Connect Chiny",
"garminErrors": {
"keyRefused": "Garmin nie przyjął tego klucza. Sprawdź, czy został skopiowany w całości, należy do wybranej usługi i nie wygasł ani nie został unieważniony.",
"rateLimited": "Garmin ogranicza teraz liczbę zapytań. Spróbuj ponownie za kilka minut.",
"unreachable": "Nie można połączyć się z Garmin. Spróbuj ponownie za kilka minut.",
"invalid": "Ten klucz wygląda nieprawidłowo. Wklej go ponownie, bez spacji.",
"generic": "Nie udało się. Spróbuj ponownie."
},
"garminKeyValidityMenu": "Ważność klucza",
"garminValiditySaved": "Zapisano ważność.",
"garminKeyCreatedOn": "Utworzono",
"garminKeyCreatedOnHelp": "Dzień, w którym utworzono klucz w Garmin Connect. Jeśli zostawisz puste, ważność liczy się od dnia połączenia go tutaj.",
"garminUnavailable": "Garmin nie udostępnia tych danych dla Twojego konta: {list}.",
"garminResource": {
"daily": "podsumowania dzienne",
"activities": "aktywności",
"sleep": "sen",
"hrv": "HRV",
"weight": "pomiary wagi",
"cycle": "cykl menstruacyjny"
}
},
"admin": {
"title": "Administracja",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "Plik jest za duży (maks. 12 MB).",
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
"pdfRendererUnavailable": "Procesor tego serwera nie potrafi renderować stron PDF, więc zeskanowany wynik odczyta tu tylko dostawca AI, który przyjmuje pliki PDF bezpośrednio. Prześlij zamiast tego zdjęcie.",
"refTextLabel": "Zakres tak jak wydrukowano",
"refTextPlaceholder": "np. 3,5 - 5,0",
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne."
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne.",
"errorUnitMismatchNoUnit": "Ten wskaźnik jest prowadzony bez jednostki. Zaznacz Bez jednostki, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
"unitDiffersBadgeNoUnit": "Prowadzony bez jednostki; ten wynik podaje {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji."
},
"interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}."
},
"unit": {
"none": "Bez jednostki",
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
}
},
"modules": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "Nie udało się otworzyć zapisanego pliku do odczytu.",
"raster-failed": "Nie udało się przetworzyć stron PDF do odczytu. Znaną przyczyną są skany w faksowych formatach kompresji.",
"provider-error": "Dostawca AI nie zdołał odczytać tego dokumentu przy ostatniej próbie.",
"pdf-needs-anthropic": "Skonfigurowany dostawca AI nie odczytuje plików PDF. Poradzi sobie z tym dostawca Claude lub lokalny OCR.",
"pdf-needs-anthropic": "Procesor tego serwera nie potrafi renderować stron PDF, a ten PDF nie ma warstwy tekstowej. Odczyta go dostawca AI, który przyjmuje pliki PDF bezpośrednio, albo prześlij zdjęcia stron.",
"empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu.",
"resource-limit": "Ten PDF jest zbyt duży lub zbyt złożony, żeby odczytać go tutaj. Sam plik jest w porządku i nadal się otwiera."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minuty.",
"errorRateLimitedWaitOther": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minut.",
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
"errorPdfRendererUnavailable": "Procesor tego serwera nie potrafi renderować stron PDF, więc zeskanowany PDF odczyta tu tylko dostawca AI, który przyjmuje pliki PDF bezpośrednio, albo prześlij zdjęcia stron.",
"errorFileType": "Tego typu pliku nie można odczytać.",
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
"errorOutputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj modelu, który pozwala na dłuższe odpowiedzi.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "W razie potrzeby",
"startsOn": "Początek",
"endsOn": "Koniec"
}
},
"unitlessHint": "Iloraz lub wskaźnik bez jednostki (INR, cholesterol/HDL): wpisz 1 w kolumnie jednostki. Pusta jednostka nadal jest błędem."
}
}
+260 -52
View File
@@ -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",
@@ -969,7 +970,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Insira uma duração como 7,5, 7:30 ou 7h 30m.",
"durationTooLong": "Uma entrada não pode durar mais de 24 horas.",
"sourceHealthConnect": "Health Connect"
"sourceHealthConnect": "Health Connect",
"typeBodyBattery": "Body Battery",
"typeStressLevel": "Nível de estresse",
"typeTrainingReadiness": "Prontidão para treinar",
"typeAcuteTrainingLoad": "Carga de treino aguda",
"sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "O que o HealthLog pode acompanhar",
@@ -1895,6 +1901,7 @@
"intakeHistoryColScheduled": "Programada",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Fonte",
"intakeHistoryInjectionSite": "Local da injeção: {site}",
"intakeHistoryStatusTaken": "Tomada",
"intakeHistoryStatusSkipped": "Pulada",
"intakeHistorySortByTaken": "Ordenar por horário da tomada",
@@ -2280,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",
@@ -2352,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",
@@ -3231,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": {
@@ -3331,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.",
@@ -3935,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).",
@@ -3989,7 +3996,7 @@
"bedtime": "Hora de dormir",
"unspecified": "Sem marcação",
"readingsShort": "{count} leituras",
"readingsShortOne": "1 leitura"
"readingsShortOne": "{count} leitura"
}
}
}
@@ -4758,7 +4765,8 @@
"unitScore": "pontuação",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carga"
},
"sleepQuality": {
"title": "Qualidade do sono",
@@ -4780,7 +4788,11 @@
"cardioLoadExplainer": "Um proxy de carga de treino que acompanha a carga cardiovascular aguda que você acumulou. Compare com os seus próprios dias recentes.",
"averageHeartRateExplainer": "Sua frequência cardíaca média ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
"maxHeartRateExplainer": "Seu pico de frequência cardíaca ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente."
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente.",
"bodyBatteryExplainer": "Sua reserva de energia na escala de 0 a 100 da Garmin, a carga mais alta do dia. Descanso e sono a recarregam, estresse e atividade a consomem.",
"trainingReadinessExplainer": "O quanto seu corpo está pronto para um treino pesado, na escala de 0 a 100 da Garmin. O valor chega por uma ponte que você configurou, pois a Garmin não o compartilha diretamente.",
"stressLevelExplainer": "O nível médio de estresse do dia segundo a Garmin, numa escala de 0 a 100, lido da variabilidade da sua frequência cardíaca. Mais baixo é mais calmo.",
"acuteTrainingLoadExplainer": "A carga de treino aguda da Garmin: a carga ponderada das suas atividades dos últimos sete dias. Chega por uma ponte que você configurou. Compare com as suas próprias semanas recentes."
},
"loadError": "Não foi possível carregar as suas leituras de recuperação."
},
@@ -4923,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"
@@ -4937,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",
@@ -4971,6 +4983,40 @@
"emptyDescription": "Nenhuma pontuação foi registrada nesta faixa ainda.",
"usualRange": "Sombreado: sua faixa habitual, {lo} a {hi}.",
"seam": "A linha quebra onde o que conta para a pontuação mudou."
},
"bpTimeOfDay": {
"title": "Por período do dia",
"description": "Sua pressão arterial média em cada período do dia, no intervalo do gráfico.",
"part": {
"morning": "Manhã",
"midday": "Tarde",
"evening": "Noite"
},
"filterAll": "Dia todo",
"filterLabel": "Mostrar no gráfico",
"readingsOne": "{count} medição",
"readingsFew": "{count} medições",
"readingsOther": "{count} medições",
"notEnough": "Poucas medições",
"pulse": "Pulso {value} bpm",
"difference": "Manhã menos noite: {systolic}/{diastolic} mmHg.",
"differencePending": "Manhã e noite são comparadas quando cada uma tiver pelo menos {min} medições.",
"hours": "Manhã {morning}, tarde {midday}, noite {evening}, no seu fuso horário.",
"hourRange": "{start}–{end}",
"emptyTitle": "Nenhuma medição completa neste intervalo",
"emptyDescription": "Medições com valor sistólico e diastólico aparecem aqui.",
"error": "Não foi possível carregar as médias por período do dia.",
"chartTitle": {
"morning": "Pressão arterial pela manhã",
"midday": "Pressão arterial à tarde",
"evening": "Pressão arterial à noite"
},
"statsLabel": "{series}, {part}",
"statsPart": {
"morning": "pela manhã",
"midday": "ao meio-dia",
"evening": "à noite"
}
}
},
"thresholds": {
@@ -5232,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5239,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": {
@@ -5293,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."
@@ -5393,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": {
@@ -5653,7 +5702,11 @@
"recovery": "Recuperação",
"stress": "Estresse",
"sleepDebt": "Déficit de sono",
"hrvRmssd": "VFC (RMSSD)"
"hrvRmssd": "VFC (RMSSD)",
"bodyBattery": "Body Battery",
"stressLevel": "Nível de estresse",
"trainingReadiness": "Prontidão para treinar",
"acuteTrainingLoad": "Carga de treino aguda"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5671,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo externo",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Relógio",
@@ -6853,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."
@@ -7465,11 +7521,6 @@
"lastEnteredHere": "Último registro feito aqui {relative}"
}
},
"garminInfo": {
"title": "Garmin",
"body": "O Garmin não tem conexão direta aqui. Conecte pelo Apple Health (iOS) ou pelo Google Health Connect (Android). As pontuações próprias da Garmin (Body Battery, Training Load, HRV Status, VO₂ máx.) não são compartilhadas pela Garmin com nenhuma das duas plataformas.",
"docsLink": "Ler o guia"
},
"glucoseReference": {
"title": "Faixa de referência da glicose",
"description": "Com qual faixa as suas leituras de glicose são comparadas.",
@@ -7669,7 +7720,58 @@
"deleteAccountConfirmDescriptionGrace": "Você sai da sessão e sua conta é bloqueada na hora. Depois de {days} dias, ela é apagada de vez, com todos os dados de saúde, chaves de acesso, sessões e registros de auditoria. Até lá, um administrador deste servidor pode desfazer a exclusão. As cópias nos backups deste servidor expiram depois do período de retenção.",
"deleteAccountScheduled": "Sua conta está bloqueada e será apagada em {date}. Saindo da sessão.",
"dangerZoneSuccessGrace": "Seu registro de saúde foi excluído. Um administrador deste servidor pode restaurá-lo até {date}.",
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo."
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo.",
"garmin": "Garmin",
"garminTag": "Personal API",
"garminDescription": "Cole uma chave da Garmin Personal API para sincronizar sono, VFC, Body Battery, estresse, passos, peso e atividades do Garmin Connect.",
"garminSync": "Sincronizar agora",
"garminSyncResult": "{count} registros sincronizados",
"garminSyncFailed": "Falha na sincronização",
"garminViewData": "Ver sua recuperação",
"garminReplaceKey": "Substituir chave",
"garminDisconnect": "Desconectar",
"garminDisconnectTitle": "Desconectar a Garmin?",
"garminDisconnectDescription": "As próximas sincronizações param e a chave é removida deste servidor. Os dados já importados continuam no seu histórico. A chave continua válida na Garmin até você revogá-la lá.",
"garminKeyValidUntil": "Chave válida até {date}",
"garminKeyExpired": "Sua chave Garmin expirou em {date}. Crie uma nova chave no Garmin Connect e cole-a aqui para retomar a sincronização.",
"garminKeyExpiresToday": "Sua chave Garmin expira hoje. Crie uma nova chave no Garmin Connect e cole-a aqui para a sincronização não parar.",
"garminKeyExpiring": "Sua chave Garmin expira em {date}. Antes disso, crie uma nova chave no Garmin Connect e cole-a aqui.",
"garminKeyExpiryUnknown": "A Garmin não informa quando esta chave expira. Substitua a chave e escolha a validade definida no Garmin Connect, e este cartão avisará você com uma semana de antecedência.",
"garminConnected": "Garmin conectada. A primeira sincronização está a caminho.",
"garminKeyHowTo": "No Garmin Connect, abra Configurações, depois Personal API, e crie uma chave. A Garmin mostra a chave só uma vez, então copie-a na hora.",
"garminKeySettingsLink": "Abrir o Garmin Connect",
"garminApiKey": "Chave da Personal API",
"garminApiKeyPlaceholder": "Cole a chave",
"garminApiKeySavedPlaceholder": "Salva. Cole uma nova chave para substituí-la",
"garminKeyValidity": "Validade escolhida",
"garminKeyValidityUnknown": "Não sei",
"garminKeyValidityDays": "{count} dias",
"garminKeyValidityHelp": "A Garmin não informa quando uma chave expira. Com a validade, este cartão avisa você uma semana antes de a chave parar de funcionar.",
"garminConnect": "Conectar",
"garminUpdate": "Salvar alterações",
"garminRegion": "Serviço Garmin",
"garminRegionGlobal": "Garmin Connect (global)",
"garminRegionChina": "Garmin Connect China",
"garminErrors": {
"keyRefused": "A Garmin não aceitou esta chave. Verifique se ela foi copiada inteira, pertence ao serviço escolhido e não expirou nem foi revogada.",
"rateLimited": "A Garmin está limitando as solicitações agora. Tente de novo em alguns minutos.",
"unreachable": "Não foi possível alcançar a Garmin. Tente de novo em alguns minutos.",
"invalid": "Essa chave não parece correta. Cole-a de novo, sem espaços.",
"generic": "Não deu certo. Tente de novo."
},
"garminKeyValidityMenu": "Validade da chave",
"garminValiditySaved": "Validade salva.",
"garminKeyCreatedOn": "Criada em",
"garminKeyCreatedOnHelp": "O dia em que você criou a chave no Garmin Connect. Deixe vazio para contar a partir do dia em que a conectou aqui.",
"garminUnavailable": "A Garmin não fornece estes dados para sua conta: {list}.",
"garminResource": {
"daily": "resumos diários",
"activities": "atividades",
"sleep": "sono",
"hrv": "VFC",
"weight": "pesagens",
"cycle": "ciclo menstrual"
}
},
"admin": {
"title": "Administração",
@@ -7913,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",
@@ -9924,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",
@@ -10220,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",
@@ -10393,14 +10559,33 @@
"fileTooLarge": "O arquivo é grande demais (máx. 12 MB).",
"fileType": "Envie um JPEG, PNG, WebP ou PDF.",
"extractFailed": "Não foi possível ler o laudo. Tente uma foto mais nítida.",
"pdfNeedsAnthropic": "A digitalização de PDF precisa de um provedor de visão Claude; envie uma foto em vez disso.",
"pdfRendererUnavailable": "O processador deste servidor não consegue renderizar páginas de PDF, então um laudo digitalizado só pode ser lido por um provedor de IA que aceite arquivos PDF diretamente. Envie uma foto em vez disso.",
"refTextLabel": "Faixa como impressa",
"refTextPlaceholder": "ex.: 3,5 - 5,0",
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora."
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora.",
"errorUnitMismatchNoUnit": "Este marcador é acompanhado sem unidade. Marque Sem unidade se o laudo foi lido errado, ou desmarque esta leitura.",
"unitDiffersBadgeNoUnit": "Acompanhado sem unidade; este laudo diz {unit}"
},
"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}",
@@ -10447,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",
@@ -10474,6 +10660,10 @@
"stale": "O resultado mais recente é de {date}, há mais de um ano. A linha é mostrada até essa data, sem projeção."
},
"interval": "Intervalo de 95 % da mudança anual: {lo} a {hi} {unit}."
},
"unit": {
"none": "Sem unidade",
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
}
},
"modules": {
@@ -10845,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.",
@@ -10858,7 +11048,7 @@
"resolved": "Resolvidos"
},
"flares": {
"countOne": "1 surto",
"countOne": "{count} surto",
"countOther": "{count} surtos"
},
"timeline": {
@@ -10990,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": {
@@ -11100,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": {
@@ -11903,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": {
@@ -11954,7 +12145,7 @@
"decrypt-error": "O arquivo armazenado não pôde ser aberto para leitura.",
"raster-failed": "As páginas do PDF não puderam ser renderizadas para leitura. Formatos de compressão de fax digitalizado são uma causa conhecida.",
"provider-error": "O provedor de IA não conseguiu ler este documento na última tentativa.",
"pdf-needs-anthropic": "O provedor de IA configurado não consegue ler arquivos PDF. Um provedor Claude ou OCR local consegue.",
"pdf-needs-anthropic": "O processador deste servidor não consegue renderizar páginas de PDF e este PDF não tem camada de texto. Um provedor de IA que aceite arquivos PDF diretamente consegue ler, ou envie fotos das páginas.",
"empty-transcription": "O provedor de IA não retornou texto para este documento.",
"resource-limit": "Este PDF é grande ou complexo demais para ser lido aqui. O arquivo em si está bem e continua abrindo."
}
@@ -12037,7 +12228,7 @@
"errorRateLimitedWaitFew": "O limite horário de leitura por IA foi atingido. Tente novamente em cerca de {count} minutos.",
"errorRateLimitedWaitOther": "O limite horário de leitura por IA foi atingido. Tente novamente em cerca de {count} minutos.",
"errorBudget": "Seu orçamento de IA para hoje foi esgotado.",
"errorPdfNeedsVision": "Digitalizar PDFs precisa de um provedor de visão Claude.",
"errorPdfRendererUnavailable": "O processador deste servidor não consegue renderizar páginas de PDF, então um PDF digitalizado só pode ser lido por um provedor de IA que aceite arquivos PDF diretamente, ou como fotos das páginas.",
"errorFileType": "Este tipo de arquivo não pode ser lido.",
"errorNotIndexed": "Leia o documento primeiro, depois extraia.",
"errorOutputLimit": "O provedor de IA atingiu o limite de saída antes de devolver um único valor. Tente um modelo que permita respostas mais longas.",
@@ -12057,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",
@@ -12089,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.",
@@ -13054,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": {
@@ -13231,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": {
@@ -13408,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",
@@ -13475,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",
@@ -13671,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"
},
@@ -13704,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": {
@@ -13728,7 +13925,7 @@
"apply": "Aplicar"
},
"item": {
"labValuesOne": "1 valor laboratorial",
"labValuesOne": "{count} valor laboratorial",
"labValuesFew": "{count} valores laboratoriais",
"labValuesOther": "{count} valores laboratoriais"
},
@@ -13747,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",
@@ -14093,6 +14300,7 @@
"asNeeded": "Conforme necessário",
"startsOn": "Início",
"endsOn": "Fim"
}
},
"unitlessHint": "Uma razão ou um índice sem unidade (INR, colesterol/HDL): escreva 1 na coluna da unidade. Uma unidade vazia continua sendo um erro."
}
}
+14306
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -309,6 +309,9 @@ const nextConfig: NextConfig = {
"./src/lib/documents/pdf-child.mjs",
"./src/lib/documents/pdf-engine.mjs",
"./src/lib/documents/image-engine.mjs",
// The doctor-report PDF embeds its fonts, read from disk by a path the
// tracer cannot follow (`src/lib/doctor-report-pdf/fonts.ts`).
"./src/lib/doctor-report-pdf/fonts/**",
// Document AI local text extraction and rasterization both run the
// app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the
// text read is the provider-free fallback that keeps content search
@@ -324,13 +327,11 @@ const nextConfig: NextConfig = {
// image-only-wire provider (codex/OAuth) can read it — the ambient
// auto-read path. `@napi-rs/canvas` loads a native `.node` binary that the
// Turbopack NFT tracer resolves dynamically and therefore misses, so pin
// the JS loader AND the two musl prebuilts (the Alpine runtime image is
// amd64 + arm64) into the standalone image explicitly. pnpm keeps them
// unhoisted under `.pnpm`. No cairo/pango apk is needed — the prebuilt
// `.node` is self-contained.
// the JS loader into the standalone image explicitly. The glibc prebuilt
// the Debian runtime image loads is placed by the Dockerfile's canvas
// hoist step, which `canvas-binary-hoist-guard.test.ts` pins. No system
// cairo/pango is needed: the prebuilt `.node` is self-contained.
"./node_modules/.pnpm/@napi-rs+canvas@*/node_modules/@napi-rs/canvas/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-x64-musl@*/node_modules/@napi-rs/canvas-linux-x64-musl/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-arm64-musl@*/node_modules/@napi-rs/canvas-linux-arm64-musl/**",
],
},
// v1.4.34 — silence the Turbopack NFT trace warnings emitted
+21 -21
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.46.3",
"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",
@@ -54,35 +54,35 @@
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1155.0",
"@aws-sdk/lib-storage": "3.1155.0",
"@aws-sdk/client-s3": "^3.1161.0",
"@aws-sdk/lib-storage": "3.1161.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@modelcontextprotocol/sdk": "^1.32.1",
"@napi-rs/canvas": "1.0.13",
"@modelcontextprotocol/sdk": "^1.32.2",
"@napi-rs/canvas": "1.1.0",
"@node-rs/argon2": "^2.2.2",
"@parse/node-apn": "^8.1.0",
"@parse/node-apn": "^8.2.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-virtual": "^3.14.22",
"@tanstack/react-query": "5.105.3",
"@tanstack/react-virtual": "^3.14.26",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^18.0.8",
"dotenv": "^18.0.9",
"fflate": "^0.8.3",
"jose": "^6.2.13",
"jose": "^6.2.15",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.9",
"lucide-react": "^1.61.0",
"mmdb-lib": "^3.0.3",
"next": "16.4.2",
"nodemailer": "10.2.1",
"otpauth": "^9.5.2",
"lucide-react": "^1.67.0",
"mmdb-lib": "^3.0.4",
"next": "16.4.5",
"nodemailer": "10.3.1",
"otpauth": "^9.6.0",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
"pg-boss": "^12.38.0",
@@ -107,15 +107,15 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
"@next/bundle-analyzer": "16.4.2",
"@next/bundle-analyzer": "16.4.5",
"@playwright/test": "^1.64.1",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/core": "10.1.1",
"@stryker-mutator/vitest-runner": "10.0.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
"@tesseract.js-data/eng": "1.0.0",
"@testcontainers/postgresql": "^12.2.1",
"@types/node": "26.6.5",
"@types/node": "26.6.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
@@ -125,14 +125,14 @@
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
"eslint-config-next": "16.4.2",
"eslint-config-next": "16.4.5",
"fast-check": "4.10.2",
"knip": "^6.42.0",
"knip": "^6.44.1",
"lefthook": "^2.2.2",
"pdfjs-dist": "6.4.299",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.22.1",
"shadcn": "^4.23.0",
"tailwindcss": "^4",
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
+650 -620
View File
File diff suppressed because it is too large. Load diff
+15 -17
View File
@@ -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"
@@ -109,6 +112,11 @@ overrides:
# under vitest, never in the image; vitest asks for ^8, so a floor on the
# same major moves it and nothing else.
"vite@>=8.0.0 <8.1.6": "^8.1.6"
# GHSA-86w9-cpqp-85rv, GHSA-7wch-p9px-956x, GHSA-jc66-7q78-782g,
# GHSA-8hrf-8g6f-7xwp, GHSA-hqq3-7jhf-c5jx, GHSA-5x38-2vff-x55p: signature,
# certificate-chain and TLS-record flaws in node-forge <= 1.4.0. It arrives
# only through @parse/node-apn; 1.4.2 is the release node-apn 8.1.1 pins.
"node-forge@<1.4.2": "^1.4.2"
allowBuilds:
"@prisma/engines": true
@@ -129,15 +137,5 @@ minimumReleaseAgeExclude:
- lucide-react@1.27.0
- undici@8.10.0
auditConfig:
ignoreGhsas:
# node-forge <=1.4.0, RSA PKCS#1 v1.5 signature verification. It arrives
# only through @parse/node-apn, which uses it for certificate-based APNs
# credentials (p12 / PEM certificate). HealthLog authenticates to APNs
# with a token (.p8 key, APNS_KEY_ID / APNS_TEAM_ID), so that code path
# never runs. No patched release is published yet (latest is 1.4.0);
# remove this entry and raise the floor once 1.4.1 is out.
- GHSA-86w9-cpqp-85rv
patchedDependencies:
"@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch
@@ -0,0 +1,30 @@
-- Google Health user id repair.
--
-- `google_health_connections.google_user_id` held the last path segment of
-- the profile's resource name `users/{user}/profile`, which is the constant
-- `profile` for every account. Since the one-account-per-record check every
-- connection therefore looked like the same Google account, and a second
-- record on the instance could not connect or reconnect (`already_linked`).
--
-- The column becomes nullable (null = id unknown, which the check treats as
-- no evidence of a second holder) and the placeholder values are cleared. The
-- next connect of each record stores the real id. The table holds one row per
-- connected record, so the update is small; the lock wait is bounded.
--
-- Reversibility: the cleared values were the same constant on every row and
-- identified nothing; restoring NOT NULL needs a value on every row first.
--
-- Both statements are repeatable, so a run stopped by the lock timeout is
-- safe to retry.
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
ALTER TABLE "google_health_connections"
ALTER COLUMN "google_user_id" DROP NOT NULL;
UPDATE "google_health_connections"
SET "google_user_id" = NULL
WHERE "google_user_id" IN ('profile', 'me', '');
END
$migration$;
@@ -0,0 +1,34 @@
-- Garmin Personal API (#1333): a per-user key pasted once, a new measurement
-- source for the rows the sync writes, Garmin's own day scores (Body Battery,
-- stress level), and two types a bridge pushes through the ingest token
-- because the Personal API does not carry them (Training Readiness, acute
-- training load).
--
-- One DO block, so the lock wait is bounded and the file is one statement
-- that rolls back whole. Adding an enum value inside a transaction is fine
-- since PostgreSQL 12 as long as the value is not used in it, and nothing
-- here uses them.
--
-- Purely additive: nullable columns, no default, no backfill. IF NOT EXISTS
-- keeps a replay harmless. Enum values cannot be dropped in PostgreSQL, so
-- there is no down path for them; dropping the six columns undoes the rest.
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GARMIN';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_BATTERY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_LEVEL';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TRAINING_READINESS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACUTE_TRAINING_LOAD';
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "garmin_api_key_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "garmin_key_expires_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "garmin_connected_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "garmin_region" TEXT,
ADD COLUMN IF NOT EXISTS "garmin_backfill_progress" JSONB,
ADD COLUMN IF NOT EXISTS "garmin_backfill_completed_at" TIMESTAMP(3);
END
$migration$;
@@ -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';
@@ -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$;
+109 -1
View File
@@ -320,6 +320,23 @@ model User {
/// account another record already holds is refused
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
ouraUserId String? @map("oura_user_id")
// v1.47 — Garmin Personal API (#1333, per-user, encrypted at app level). The
// person creates a key in Garmin Connect and pastes it once; the sync
// exchanges it for a short-lived token on every run, so the key is the only
// credential at rest. The token itself is never stored.
/// Sealed Garmin Personal API key.
garminApiKeyEncrypted String? @map("garmin_api_key_encrypted")
/// v1.47 — when the Garmin key stops working: learned from the token's own expiry once it is capped by the key's, else the validity the person picked at connect counted from then. Null when neither is known. Drives the card's expiry warning.
garminKeyExpiresAt DateTime? @map("garmin_key_expires_at")
/// v1.47 — when the current Garmin key was connected; the anchor the picked validity counts from.
garminConnectedAt DateTime? @map("garmin_connected_at")
/// v1.47 — which Garmin service accepted the key at connect: `global` (personal.connectapi.garmin.com) or `china` (…garmin.cn). Not a secret; the sync reads it to pick the host.
garminRegion String? @map("garmin_region")
/// v1.47 — how far the Garmin history backfill has got, per resource (see `src/lib/garmin/backfill-progress.ts`). Cleared on connect and disconnect.
garminBackfillProgress Json? @map("garmin_backfill_progress")
/// v1.47 — the Garmin history backfill finished (discovery predicate `garmin_backfill_completed_at IS NULL`). Cleared on connect.
garminBackfillCompletedAt DateTime? @map("garmin_backfill_completed_at")
/// v1.44 — which provider apps on this record were copied from a
/// guardian at connect, and from whom: `{ "<provider>": "<guardianUserId>" }`.
/// A managed profile has no settings page for its own app, so a guardian's is
@@ -1149,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
@@ -1783,6 +1806,16 @@ enum MeasurementType {
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
// v1.47 — Garmin Personal API (#1333). Garmin's own scores keep types of
// their own, so a device-native figure never shares a bucket with the
// COMPUTED proxies or another vendor's score of a different scale.
BODY_BATTERY // score (0–100) — Garmin Body Battery, the day's highest charge. Source GARMIN.
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")
}
@@ -2018,6 +2051,11 @@ enum MeasurementSource {
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
// decodes the rows.
HEALTH_CONNECT
// v1.47 — Garmin Personal API (#1333). Server-owned: only the Garmin sync
// writes these rows, from the key the user pasted, so the batch +
// single-POST surfaces reject it like the other providers. iOS DTO must
// mirror this spelling so the /api/sync/changes delta feed decodes the rows.
GARMIN
@@map("measurement_source")
}
@@ -4614,7 +4652,7 @@ model FitbitOAuthState {
model GoogleHealthConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
googleUserId String @map("google_user_id") // Google external account id
googleUserId String? @map("google_user_id") // Google Health user id; null when the connect could not read it
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
@@ -8296,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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"],
"locales": ["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR", "ru"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.5.5/dist/index.js"
],
+1 -1
View File
@@ -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.46.3";
/* @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/*`
+1
View File
@@ -31,6 +31,7 @@ typecheck.
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
- **`install-hooks.sh`** — install the local git hooks.
- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
+10 -3
View File
@@ -224,10 +224,11 @@ describe("release container inputs", () => {
const dockerfile = readRepoFile("Dockerfile");
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
expect(fromLines).toHaveLength(3);
// deps, builder, the prisma-cli install and the runner.
expect(fromLines).toHaveLength(4);
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
new Set([
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
"FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f",
]),
);
});
@@ -243,8 +244,14 @@ describe("release container inputs", () => {
expect(
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
).toHaveLength(2);
// The Prisma CLI is installed in its own stage and copied over; the
// runner removes the base image's package managers and fails the build
// if any is left (runtime-package-pins holds the rest).
expect(dockerfile).toContain(
"rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack",
"COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli",
);
expect(dockerfile).toContain(
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
);
expect(dockerfile).toContain("tsx@4.23.1");
expect(dockerfile).toContain(
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Build the fonts the doctor-report PDF embeds.
jsPDF draws one font per text run and has no glyph fallback, so every script
the report can carry has to live in one font file. No single upstream Noto
font covers what the report needs: Noto Sans has Latin, Greek and Cyrillic but
no arrows or comparison signs, Noto Sans KR has Hangul but no Latin
Extended-A (Polish). This script merges them:
ReportSans-{Regular,Bold,Italic}.ttf
Noto Sans (Latin, Latin Extended-A/B, Greek, Cyrillic, punctuation,
super-/subscripts, currency) plus the arrows and mathematical operators
of Noto Sans Math.
ReportSansKR-{Regular,Bold}.ttf
The matching ReportSans weight plus Hangul (syllables, jamo,
compatibility jamo) and CJK punctuation from Noto Sans KR, instanced
at weight 400 / 700. Loaded only for a report that contains Hangul.
All sources are SIL Open Font License 1.1; the licence texts sit beside the
output. The merged fonts carry their own family name, as the OFL asks of a
modified version. Layout tables (GSUB/GPOS/GDEF/MATH) are dropped: jsPDF
does no shaping, and precomposed Hangul syllables need none.
Requires fonttools (`pip install fonttools`). Usage:
python3 scripts/build-pdf-fonts.py <download-dir> <output-dir>
where <download-dir> holds the unhinted upstream TTFs:
NotoSans-Regular.ttf, NotoSans-Bold.ttf, NotoSans-Italic.ttf
https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSans/unhinted/ttf
NotoSansMath-Regular.ttf
https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSansMath/unhinted/ttf
NotoSansKR[wght].ttf
https://github.com/google/fonts/tree/main/ofl/notosanskr
"""
import copy
import sys
from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
def ranges(*spans):
out = set()
for lo, hi in spans:
out.update(range(lo, hi + 1))
return out
BASE_CODEPOINTS = ranges(
(0x0020, 0x007E), # Basic Latin
(0x00A0, 0x024F), # Latin-1, Latin Extended-A/B
(0x0250, 0x02FF), # IPA, spacing modifiers
(0x0300, 0x036F), # combining diacritics
(0x0370, 0x03FF), # Greek
(0x0400, 0x052F), # Cyrillic + supplement
(0x1E00, 0x1EFF), # Latin Extended Additional
(0x2000, 0x206F), # general punctuation
(0x2070, 0x209F), # super-/subscripts
(0x20A0, 0x20CF), # currency
(0x2100, 0x214F), # letterlike (°C, №, ™)
(0x2150, 0x218F), # number forms
)
SYMBOL_CODEPOINTS = ranges(
(0x2190, 0x21FF), # arrows
(0x2200, 0x22FF), # mathematical operators (≈ ≤ ≥ ±)
)
HANGUL_CODEPOINTS = ranges(
(0x1100, 0x11FF), # Hangul jamo
(0x3000, 0x303F), # CJK symbols and punctuation
(0x3130, 0x318F), # compatibility jamo
(0xA960, 0xA97F), # jamo extended-A
(0xAC00, 0xD7A3), # syllables
(0xD7B0, 0xD7FF), # jamo extended-B
)
DROP_TABLES = ["GSUB", "GPOS", "GDEF", "MATH", "BASE", "JSTF", "DSIG", "STAT"]
def subset_font(font: TTFont, codepoints: set[int]) -> TTFont:
options = subset.Options()
options.layout_features = []
options.drop_tables += DROP_TABLES
options.hinting = False
options.notdef_outline = True
options.name_IDs = ["*"]
options.glyph_names = True
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=codepoints & set(font.getBestCmap()))
subsetter.subset(font)
return font
def copy_glyph(base: TTFont, donor: TTFont, name: str, prefix: str) -> str:
"""Copy one donor glyph (and its components) into base; return its name."""
new_name = f"{prefix}{name}"
glyf = base["glyf"]
if new_name in glyf.glyphs:
return new_name
glyph = copy.deepcopy(donor["glyf"][name])
if glyph.isComposite():
for component in glyph.components:
component.glyphName = copy_glyph(
base, donor, component.glyphName, prefix
)
glyf.glyphs[new_name] = glyph
glyf.glyphOrder.append(new_name)
base["hmtx"].metrics[new_name] = donor["hmtx"].metrics[name]
order = base.getGlyphOrder()
if new_name not in order:
order.append(new_name)
base.setGlyphOrder(order)
return new_name
def merge(base: TTFont, donor: TTFont, prefix: str) -> None:
if base["head"].unitsPerEm != donor["head"].unitsPerEm:
raise SystemExit("unitsPerEm differs between base and donor")
base_cmap = base.getBestCmap()
additions = {}
for cp, name in donor.getBestCmap().items():
if cp in base_cmap:
continue
additions[cp] = copy_glyph(base, donor, name, prefix)
for table in base["cmap"].tables:
if table.isUnicode():
if table.format == 4 and any(cp > 0xFFFF for cp in additions):
continue
table.cmap.update(additions)
base["maxp"].numGlyphs = len(base.getGlyphOrder())
base["hhea"].numberOfHMetrics = len(base.getGlyphOrder())
base["hhea"].advanceWidthMax = max(
base["hhea"].advanceWidthMax,
max(width for width, _ in donor["hmtx"].metrics.values()),
)
def finish(font: TTFont, family: str, style: str, path: Path) -> None:
"""Rename, drop glyph names, save.
jsPDF copies the whole `post` table into every PDF it writes, subset or
not, so glyph names would cost each report tens of kilobytes (hundreds
with Hangul). Format 3 carries none.
"""
font["post"].formatType = 3.0
rename(font, family, style)
font.save(path)
def rename(font: TTFont, family: str, style: str) -> None:
name = font["name"]
postscript = f"{family}-{style}"
for record in list(name.names):
if record.nameID in (1, 16):
record.string = family
elif record.nameID in (2, 17):
record.string = style
elif record.nameID == 3:
record.string = f"{postscript};HealthLog"
elif record.nameID == 4:
record.string = f"{family} {style}"
elif record.nameID == 6:
record.string = postscript
def build(src: Path, out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
math = subset_font(TTFont(src / "NotoSansMath-Regular.ttf"), SYMBOL_CODEPOINTS)
kr_source = src / "NotoSansKR[wght].ttf"
for style in ("Regular", "Bold", "Italic"):
base = subset_font(TTFont(src / f"NotoSans-{style}.ttf"), BASE_CODEPOINTS)
merge(base, math, "math.")
finish(base, "ReportSans", style, out / f"ReportSans-{style}.ttf")
if style == "Italic":
continue
weight = 400 if style == "Regular" else 700
kr = subset_font(TTFont(kr_source), HANGUL_CODEPOINTS)
kr = instancer.instantiateVariableFont(kr, {"wght": weight})
with_hangul = TTFont(out / f"ReportSans-{style}.ttf")
merge(with_hangul, kr, "kr.")
finish(with_hangul, "ReportSansKR", style, out / f"ReportSansKR-{style}.ttf")
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit(__doc__)
build(Path(sys.argv[1]), Path(sys.argv[2]))
File diff suppressed because it is too large. Load diff
+5
View File
@@ -225,6 +225,11 @@
{
"name": "DATABASE_SESSION_OPTIONS_DISABLED",
"purpose": "Set to 1, true or yes to send no session settings (statement timeout, work_mem) as the libpq options startup parameter. Needed behind PgBouncer, which refuses that parameter unless it is in ignore_startup_parameters."
},
{
"name": "DB_APP_ROLE",
"purpose": "auto (default) or off. Auto: after the migrations the entrypoint creates this database's role healthlog_app_<database>_<hash>, which may read and write rows and run the queue but not change the schema, and starts the app with it in place of the DATABASE_URL user. Off keeps the app on the DATABASE_URL user, for an operator who manages database roles.",
"example": "off"
}
]
},
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl", "pt-BR", "ru"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR", "ru"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+1
View File
@@ -308,6 +308,7 @@ async function main() {
"nightscoutTokenEncrypted",
"dawarichUrlEncrypted",
"dawarichApiKeyEncrypted",
"garminApiKeyEncrypted",
"polarAccessTokenEncrypted",
"polarUserIdEncrypted",
"polarClientIdEncrypted",
+17 -6
View File
@@ -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),
);
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR", "ru"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
+185
View File
@@ -0,0 +1,185 @@
// @ts-check
/**
* `wget` in the image (`/usr/local/bin/wget` runs this file): enough of wget
* for a container health check, and nothing more.
*
* The runtime base has no wget, but health checks written outside the image
* call it: compose files copied from this repository up to v1.46.6 run
* `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`,
* and the TrueNAS catalog app renders `wget --quiet --spider <url>`. Such a
* check replaces the image's own HEALTHCHECK, so without a `wget` the
* container would never turn healthy. The image's HEALTHCHECK and the
* bundled compose file use a `node -e` fetch instead; this file exists only
* for those external callers.
*
* Understood: the URL, `--spider`, `-q`/`--quiet`, `-nv`/`--no-verbose`,
* `-t N`/`--tries=N`, `-T N`/`--timeout=N`, `-O -` and `-O /dev/null`
* (also `-O-`, `--output-document=…`), `-S`/`--server-response`. Any other
* flag is ignored. It is not a download tool: it writes a body only to
* standard output (`-O -`), refuses any other `-O` target before sending a
* request, never follows a redirect, and speaks plain http/https only.
*
* Exit 0 on a 2xx or 3xx answer, 1 on any other status, a refused or
* failed connection, or the timeout, and 2 on a command line it will not
* run. Without `-T` a request gives up after 30 s; Docker's own health
* check timeout usually ends it sooner.
*/
import { pathToFileURL } from "node:url";
const DEFAULT_TIMEOUT_SECONDS = 30;
/** Flags whose value may follow as the next argument. */
const VALUE_FLAGS = new Set(["-O", "-T", "-t", "-o", "-U", "-P", "--header"]);
/**
* @param {string[]} argv
* @returns {{ url: string | null; spider: boolean; quiet: boolean;
* verbose: boolean; tries: number; timeout: number; output: string | null;
* serverResponse: boolean }}
*/
function parseArgs(argv) {
const opts = {
url: /** @type {string | null} */ (null),
spider: false,
quiet: false,
verbose: true,
tries: 1,
timeout: DEFAULT_TIMEOUT_SECONDS,
output: /** @type {string | null} */ (null),
serverResponse: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
/** @param {string} name */
const inline = (name) =>
arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
/** The value of a short flag, attached (`-T4`) or as the next argument. */
const shortValue = () => {
if (arg.length > 2) return arg.slice(2).replace(/^=/, "");
i += 1;
return argv[i] ?? "";
};
if (/^https?:\/\//i.test(arg)) {
opts.url ??= arg;
} else if (/^[a-z0-9.[\]:-]+(:\d+)?\//i.test(arg)) {
// busybox wget takes `127.0.0.1:3000/api/health` without a scheme.
opts.url ??= `http://${arg}`;
} else if (arg === "--spider") {
opts.spider = true;
} else if (arg === "-q" || arg === "--quiet") {
opts.quiet = true;
} else if (arg === "-nv" || arg === "--no-verbose") {
opts.verbose = false;
} else if (arg === "-S" || arg === "--server-response") {
opts.serverResponse = true;
} else if (arg.startsWith("--tries=")) {
opts.tries = count(inline("--tries"), 1);
} else if (arg.startsWith("-t")) {
opts.tries = count(shortValue(), 1);
} else if (arg.startsWith("--timeout=")) {
opts.timeout = count(inline("--timeout"), DEFAULT_TIMEOUT_SECONDS);
} else if (arg.startsWith("-T")) {
opts.timeout = count(shortValue(), DEFAULT_TIMEOUT_SECONDS);
} else if (arg.startsWith("--output-document=")) {
opts.output = inline("--output-document");
} else if (arg.startsWith("-O")) {
opts.output = shortValue();
} else if (VALUE_FLAGS.has(arg)) {
// A flag this shim ignores, with its value as the next argument.
i += 1;
} else if (/^-q[a-zA-Z]*O/.test(arg)) {
// `-qO-` / `-qO /dev/null`: quiet plus an output target, bundled.
opts.quiet = true;
const rest = arg.slice(arg.indexOf("O") + 1);
opts.output = rest || (argv[++i] ?? "");
}
// Anything else is ignored.
}
// wget treats 0 tries as "retry forever"; a health check must not.
if (opts.tries < 1) opts.tries = 1;
return opts;
}
/**
* @param {string | null} value
* @param {number} fallback
*/
function count(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
/** @param {string} message @param {boolean} quiet */
function say(message, quiet) {
if (!quiet) process.stderr.write(`wget: ${message}\n`);
}
/**
* @param {string[]} argv
* @returns {Promise<number>} the exit code
*/
async function run(argv) {
const opts = parseArgs(argv);
if (!opts.url) {
process.stderr.write("wget: missing URL (health check shim)\n");
return 2;
}
const toStdout = opts.output === "-";
if (opts.output !== null && !toStdout && opts.output !== "/dev/null") {
process.stderr.write(
`wget: refusing to write ${opts.output}: this image's wget is a health check shim (-O - and -O /dev/null only)\n`,
);
return 2;
}
let lastError = "";
for (let attempt = 1; attempt <= opts.tries; attempt += 1) {
try {
const response = await fetch(opts.url, {
redirect: "manual",
signal: AbortSignal.timeout(Math.max(opts.timeout, 0.001) * 1000),
});
if (opts.serverResponse) {
process.stderr.write(
` HTTP/1.1 ${response.status} ${response.statusText}\n`,
);
for (const [name, value] of response.headers) {
process.stderr.write(` ${name}: ${value}\n`);
}
}
if (toStdout && !opts.spider && response.body) {
for await (const chunk of response.body) process.stdout.write(chunk);
} else {
await response.body?.cancel();
}
const ok = response.status >= 200 && response.status < 400;
if (ok) {
if (opts.verbose) say(`${opts.url}: ${response.status}`, opts.quiet);
return 0;
}
say(`server returned error: HTTP ${response.status}`, opts.quiet);
return 1;
} catch (err) {
lastError =
err instanceof Error && err.name === "TimeoutError"
? "download timed out"
: err instanceof Error
? err.cause instanceof Error
? err.cause.message
: err.message
: String(err);
}
}
say(`${opts.url}: ${lastError}`, opts.quiet);
return 1;
}
if (
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
run(process.argv.slice(2)).then(
(code) => process.exit(code),
() => process.exit(1),
);
}
@@ -83,6 +83,11 @@ const ALLOWLIST: Record<string, { max: number; reason: string }> = {
reason:
"the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on",
},
"tests/integration/dense-series-dst-hour.test.ts": {
max: 1,
reason:
"the read starts at a fixed fall-back night and runs forward to now; the readings are pinned to that night, so the case cannot age",
},
};
export interface AgingFinding {
@@ -46,10 +46,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
// The slim slice now strips disabled-module types before answering, from
@@ -23,9 +23,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
@@ -3,9 +3,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -37,10 +37,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
@@ -116,6 +116,8 @@ const BACKFILL_STAMPS: Record<string, string> = {
"stamps the account's backfill-completed marker",
"src/lib/jobs/oura-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/garmin-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
@@ -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.
+13 -13
View File
@@ -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": 94362,
"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,19 +17,19 @@
"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,
"lib/ai/coach/about-me.ts": 1012,
"lib/ai/coach/coach-prose-grounding.ts": 1079,
"lib/ai/coach/coach-prose-grounding.ts": 1091,
"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": 1110,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
@@ -39,10 +39,10 @@
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 971,
"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,
@@ -62,15 +62,15 @@
"lib/insights/correlations.ts": 1026,
"lib/insights/features.ts": 1875,
"lib/insights/mood-aggregates.ts": 1053,
"lib/integrations/status.ts": 1445,
"lib/integrations/status.ts": 1448,
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1189,
"lib/jobs/medication-intake-import.ts": 819,
"lib/jobs/offhost-backup.ts": 1094,
"lib/jobs/reaction-line.ts": 803,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/record-search.ts": 1397,
"lib/mcp/rich-reads.ts": 1471,
"lib/measurements/dense-intraday-retention.ts": 1016,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 976,
@@ -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
}
}
@@ -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": 111929,
"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,
@@ -21,10 +20,10 @@
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"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,
@@ -45,7 +44,7 @@
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 376,
"app/insights/page-client.tsx :: InsightsPageClient": 375,
"app/labs/page.tsx :: LabsPage": 228,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
@@ -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,
@@ -93,7 +92,7 @@
"components/documents/document-card.tsx :: DocumentCard": 354,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325,
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
"components/documents/document-facts-review.tsx :: FactRow": 262,
"components/documents/document-facts-review.tsx :: FactRow": 261,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
@@ -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": 393,
"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,
@@ -156,7 +154,7 @@
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 427,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
"components/medications/medication-card.tsx :: MedicationCard": 413,
@@ -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,
@@ -205,9 +203,9 @@
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 460,
"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,21 +284,21 @@
"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,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
"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,
@@ -335,8 +333,8 @@
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
"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
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5483,
"total": 5476,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -360,7 +360,7 @@
"lib/documents/__tests__/assist-kinds.test.ts": 1,
"lib/documents/__tests__/commit.test.ts": 9,
"lib/documents/__tests__/fenced-chat-pipeline.test.ts": 6,
"lib/documents/__tests__/index-document.test.ts": 22,
"lib/documents/__tests__/index-document.test.ts": 15,
"lib/documents/__tests__/index-outcome-record.test.ts": 14,
"lib/documents/__tests__/prepare-vision-raster-failure.test.ts": 6,
"lib/documents/__tests__/provider-order.test.ts": 1,
@@ -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": 2895,
"total": 9226,
"count": 2892,
"total": 9220,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -824,7 +824,7 @@
"components/documents/document-ai-transport.ts": 2,
"components/documents/document-bulk-bar.tsx": 1,
"components/documents/document-card.tsx": 2,
"components/documents/document-facts-review.tsx": 2,
"components/documents/document-facts-review.tsx": 1,
"components/documents/document-summary-block.tsx": 2,
"components/documents/use-content-index.ts": 1,
"components/documents/use-document-assist.ts": 1,
@@ -917,7 +917,7 @@
"components/insights/coach-panel/chat-bubble.tsx": 25,
"components/insights/coach-panel/coach-conversation.tsx": 40,
"components/insights/coach-panel/coach-drawer-body.tsx": 6,
"components/insights/coach-panel/coach-drawer.tsx": 14,
"components/insights/coach-panel/coach-drawer.tsx": 13,
"components/insights/coach-panel/coach-hero.tsx": 2,
"components/insights/coach-panel/coach-input.tsx": 12,
"components/insights/coach-panel/coach-model-picker.tsx": 1,
@@ -958,7 +958,6 @@
"components/insights/derived/coach-read-strip.tsx": 2,
"components/insights/derived/composite-score-anatomy.tsx": 6,
"components/insights/derived/coverage-meter.tsx": 1,
"components/insights/derived/index.ts": 1,
"components/insights/derived/provenance-explainer.tsx": 2,
"components/insights/derived/ring-hues.ts": 2,
"components/insights/derived/score-anatomy-view.tsx": 3,
@@ -1073,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,
@@ -1379,7 +1378,6 @@
"components/settings/integrations/__tests__/apple-health-card.test.tsx": 2,
"components/settings/integrations/apple-health-card.tsx": 1,
"components/settings/integrations/connections-panel.tsx": 5,
"components/settings/integrations/garmin-info-note.tsx": 1,
"components/settings/integrations/notification-channels-panel.tsx": 1,
"components/settings/integrations/setup-guide-link.tsx": 2,
"components/settings/integrations/shared.tsx": 3,
@@ -1960,7 +1958,6 @@
"lib/day/values.ts": 1,
"lib/day/visit-preparation.ts": 1,
"lib/day/wire-schemas.ts": 1,
"lib/db-compat.ts": 1,
"lib/devices/__tests__/revoke.test.ts": 1,
"lib/devices/revoke.ts": 1,
"lib/doctor-report-helpers.ts": 1,
@@ -2895,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
}
@@ -6,7 +6,7 @@
*
* The pnpm store held two canvas versions: the one in package.json and an
* older one that a second, older pdfjs-dist pulled in (gone since #1277).
* The runner stage picked the musl binary with `find | head -1`, got the
* The runner stage picked the platform binary with `find | head -1`, got the
* old one, and copied it into the new loader's directory, where the loader
* looks first. PDF rendering still worked, so nothing looked wrong;
* `loadImage` called an `Image.decode` the old binary lacks, and every
@@ -43,7 +43,7 @@ describe("canvas binary hoist", () => {
);
});
it("hoists the loader and the musl binary of that version only", () => {
it("hoists the loader and the glibc binary of that version only", () => {
const runner = stage("runner");
expect(runner).toContain(
"COPY --from=builder /app/.canvas-version /tmp/.canvas-version",
@@ -51,12 +51,44 @@ describe("canvas binary hoist", () => {
expect(runner).toContain(
'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/',
);
expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/");
// The image is Debian: the glibc (`-gnu`) build is the one that runs,
// and the musl build, which needs ARMv8.2 on arm64 (#1311), is not.
expect(runner).toContain("canvas-linux-*-gnu@${CANVAS_VERSION}/");
expect(runner).toContain("-name 'skia.linux-*-gnu.node'");
expect(runner).not.toMatch(/-musl/);
// A version-blind pick is the defect: any `canvas…@*` glob decides by
// readdir order which of the store's copies wins.
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
});
it("installs and runs on the same Debian glibc image", () => {
const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? [];
expect(froms).toEqual([
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS deps$/,
),
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/,
),
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS prisma-cli$/,
),
expect.stringMatching(
/^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64} AS pgclient$/,
),
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS runner$/,
),
]);
// The native packages pnpm picked in the builder must load in the
// runner: same module ABI, and a glibc no older than the builder's.
expect(stage("builder")).toContain("> /app/.native-abi");
expect(stage("runner")).toContain(
'[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI',
);
expect(stage("runner")).toContain("is older than the builder's");
});
it("fails the build when the copied binary is another version", () => {
const runner = stage("runner");
expect(runner).toMatch(
@@ -74,9 +74,11 @@ function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
(m) => m[1],
),
// A line that starts with the call, or a command substitution that
// makes it (`url="$(node scripts/db-app-role.mjs …)"`).
[
...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm),
].map((m) => m[1]),
),
].sort();
}
@@ -126,6 +128,7 @@ describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
expect(entrypointScripts()).toContain("scripts/db-app-role.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
@@ -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<typeof vi.fn> };
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
};
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(),
);
});
});
@@ -125,6 +125,7 @@ const NO_REFETCH_ALLOWLIST = [
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/garmin-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/integration-actions.tsx",
"components/settings/integrations/nightscout-card.tsx",
@@ -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 ─────────────────────────────────────────────────────────
{
@@ -719,6 +727,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/garmin-card.tsx",
destroys:
"the stored Garmin Personal API key — reconnecting means pasting a key again, and a key Garmin showed only once may mean creating a new one",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/dawarich-card.tsx",
destroys:
@@ -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(106);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(ALL_LEAF_IDS.length).toBe(111);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
});
it("discovers all nine shipped locales", () => {
it("discovers all ten shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -177,6 +177,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
"nl",
"pl",
"pt-BR",
"ru",
]);
});
+19 -7
View File
@@ -79,10 +79,22 @@ function imageCheck(): Check {
};
}
function wgetTimeout(command: string): number {
const m = /wget[^|]*--timeout=(\d+)/.exec(command);
if (!m) throw new Error(`no wget timeout in: ${command}`);
return Number(m[1]);
/**
* The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The image
* carries no curl, and its `wget` is a shim kept for health checks written
* outside the repository (`wget-shim.test.ts`); the image's own check and the
* bundled compose file do not lean on it, so neither names one.
*/
function probeTimeout(command: string): number {
expect(command, "the image's own probe is the Node fetch").not.toMatch(
/\b(wget|curl)\b/,
);
const m =
/fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health',\{signal:AbortSignal\.timeout\((\d+)\)\}\)/.exec(
command,
);
if (!m) throw new Error(`no aborting /api/health fetch in: ${command}`);
return Number(m[1]) / 1000;
}
describe.each([
@@ -96,9 +108,9 @@ describe.each([
it("ends the request inside Docker's timeout, after the database probe's", () => {
const check = read();
const wget = wgetTimeout(check.command);
expect(wget).toBeGreaterThan(probeTimeoutSeconds);
expect(wget).toBeLessThan(check.timeout);
const abort = probeTimeout(check.command);
expect(abort).toBeGreaterThan(probeTimeoutSeconds);
expect(abort).toBeLessThan(check.timeout);
});
});
@@ -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<string>([
// #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
@@ -110,6 +114,14 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.integrations.oura.scope.items.vo2max",
// Brand / product / proper nouns.
"settings.googleHealth",
// Garmin's own names: the Body Battery score and the Personal API product,
// which Garmin Connect shows under the same English name in every locale.
"measurements.typeBodyBattery",
"settings.garminTag",
"settings.sections.sources.metrics.bodyBattery",
// Garmin's own service names.
"settings.garminRegionGlobal",
"settings.garminRegionChina",
"admin.glitchtipDsn",
"admin.integrationWebPush",
"admin.offlineGeoEnabled",
+15 -1
View File
@@ -30,6 +30,14 @@ const hasSeparator = (value: string) =>
/** Key path -> why the separator is right there. Empty by intent. */
const ALLOWED: Record<string, string> = {};
/**
* Bundles where a spaced dash is punctuation the language requires rather than
* a fragment join. Russian uses the dash where English puts a comma or a full
* stop ("Сводка за неделю — всё спокойно"), so the ru bundle is exempt from
* the dash rule; the middle dot is still flagged there.
*/
const DASH_EXEMPT_BUNDLES = new Set(["ru.json"]);
function flatten(
obj: unknown,
prefix: string,
@@ -66,7 +74,13 @@ describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () =>
it(`${file} has no dot or dash separator outside the allowlist`, () => {
const offenders = [...values]
.filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED))
.filter(([key, value]) => {
if (!hasSeparator(value) || key in ALLOWED) return false;
if (DASH_EXEMPT_BUNDLES.has(file) && !value.includes(" · ")) {
return false;
}
return true;
})
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
});
@@ -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<string, z.ZodType>;
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<string, Reader> = {
"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<string, string> = {
"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,
);
});
});
@@ -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<Record<string, BiomarkerPanelKey>>,
): { checked: number; missing: string[] } {
const panels = new Set<string>(BIOMARKER_PANELS);
const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel]));
const keys = new Set<string>([
...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<string>([
...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]);
});
});
@@ -70,6 +70,8 @@ const WRITE_SITES: Record<string, string> = {
"lib/whoop/webhook-handler.ts": TAIL,
// Retiring the rows of Oura sleep periods the person rejected or deleted.
"lib/oura/sync.ts": TAIL,
// Retiring the rows of nights Garmin no longer reports.
"lib/garmin/sync.ts": TAIL,
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
"lib/oura/retire-distance.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
@@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
"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":
Loaded 100 of 1284 files, more files were not shown because too many files have changed in this diff. Show more