Compare commits

..
302 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
Marc-André Bombeck 8f8f98eac7 Merge pull request #1302 from MBombeck/fix/google-health-sleep-sweep
fix(google-health): protect only a night's own segments in the sleep sweep
2026-10-29 23:56:59 +01:00
Marc-André Bombeck a79a04f3ef test(dawarich): take clock times out before looking for coordinates
The privacy check looks for the fixture's latitude 52.52 in every stored
row, and a timestamp written at :52.52x seconds holds the same digits. It
failed once in CI with no coordinate stored. Clock times are masked first;
a coordinate in any field is still found.
2026-10-29 23:36:32 +01:00
Marc-André Bombeck cd98db82eb test(insights): date the comparison's sleep readings by calendar day
The readings sat at now minus n times 24 hours, which keeps the wall-clock
hour only between two clock changes. Run late in the evening after the
October change, two of them fell into the same night and the average read
525 minutes instead of 420.
2026-10-29 23:16:00 +01:00
Marc-André Bombeck e3ebc01e34 docs(changelog): note the Google Health sleep sweep fix in 1.46.3 2026-10-29 23:13:43 +01:00
Marc-André Bombeck eed83befa3 fix(google-health): protect only a night's own segments in the sleep sweep
Every night's replace-by-window clean-up carried every fresh segment id of
the fetch. On a first sync that reads years of sleep that is one query per
night with tens of thousands of bound values, over Postgres's limit, and
the process spent minutes building them before each one failed. The sweep
can only reach rows inside the window, so each window now protects the
fresh rows inside it, from whichever session reported them.

Refs #1195
2026-10-29 23:13:43 +01:00
Marc-André Bombeck cb6c725a8c Merge pull request #1301 from MBombeck/release/v1.46.3
Release v1.46.3
2026-10-29 23:06:46 +01:00
Marc-André Bombeck 0e57247b29 chore(release): v1.46.3 2026-10-29 22:18:59 +01:00
Marc-André Bombeck 1bdeae5cc6 test(backup): keep the 1.46.3 backup file as a golden 2026-10-29 22:18:58 +01:00
Marc-André Bombeck 1f0b05f141 Merge branch 'fix/v1463-review' into release/v1.46.3 2026-10-29 22:18:02 +01:00
Marc-André Bombeck 3674c1757d fix(timeline): leave hidden lab markers out of the values menu
Hiding a marker takes it out of the labs catalogue and the entry
pickers, but the timeline still offered it among the lab values.

The timeline now offers only markers that are not hidden. A hidden
marker that was chosen before keeps drawing its line and stays in the
menu as a ticked entry, named from that line, so it can be unticked,
the way the entry form keeps a hidden marker that is already selected.
Once unticked it is no longer offered.
2026-10-29 22:01:43 +01:00
Marc-André Bombeck 5dec158c9d fix(ai): keep the reasoning-field day for what a 400 taught
The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.

A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
2026-10-29 21:58:40 +01:00
Marc-André Bombeck c920266f32 fix(backup): explain a second restore of one file into another account
Restored rows keep the ids they had in the file. Once an erased
account's copy had gone into one account, the same file uploaded into a
second account restored into a primary-key conflict, reported only as
the generic "the database rejected a record" failure.

For a copy put into a chosen account, a primary-key conflict now ends
the restore with backup.restore.rows_exist_elsewhere and a sentence that
says the file was most likely restored into another account already.
The transaction still rolls back, so neither account changes. The code
is in the catalogue, the console names it, and the runbook says a file
goes into one account only.
2026-10-29 21:50:34 +01:00
Marc-André Bombeck 94854bd8f7 fix(backup): trace an account made for an upload when it is made
The upload creates a passwordless account before it reads the file and
deletes it again when the file is refused. The only audit row was
written after a successful store, so a refused upload whose delete also
failed left an account nobody could sign into and no record of where it
came from.

The account and its admin.user.create row (outcome "created") now
commit together, and the discard writes admin.user.delete with outcome
"deleted" or "failed", so a leftover account always has a trace. The
runbook says how to remove one by hand.
2026-10-29 21:42:18 +01:00
Marc-André Bombeck 16d1b542a5 fix(backup): keep an erased account's copy out of admin accounts
A restore read the account settings under the operator's AI endpoint
policy whenever the receiving account was an admin. For a copy put into
a chosen account, the settings were written by the erased account's
owner, not by the operator, yet an admin account could be chosen as the
target, and an ordinary target promoted later got the same trust.

Admin accounts are now refused as a target, in the check and the
picker, with backup.target.admin_account. A copy uploaded into a chosen
account is stored as RETARGETED_UPLOAD_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck dfe34386be fix(ai): read a Codex sign-in's memory only under its own account
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.

The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck 648810745a fix(backup): let an account fresh from setup take an erased account's copy
The empty-account check counted every backed-up model, and signing up
and answering the setup questions always leaves a setup row, plus a
cycle profile when the cycle area is chosen. The account of someone who
has signed up again, the case the restore target is for, was therefore
always refused.

Those two models are one row per account and are deleted and re-created
from the copy by every restore, so they no longer count. A reading or
medication entered on setup's last screen still refuses the account,
because the restore would delete it. A guard holds the exempt models to
that shape, and the erased-account integration file now drives the real
setup routes before the restore.
2026-10-29 21:14:25 +01:00
Marc-André Bombeck 21f6f5796b fix(timeline): cap lab markers instead of refusing the request
A values list naming more than 24 lab markers answered 422. The list is a
device preference, so once a 25th marker was ticked every later visit
failed: the page rendered its error card, and the values menu that could
untick it only renders on success.

The server now keeps the first 24 markers and leaves the rest out, as it
already does for a marker the record no longer has; a malformed key still
answers 422. The page applies the same cap before it asks, and the values
menu holds further unchosen markers back at 24 with a line saying why,
while a chosen one can always be removed.
2026-10-29 21:06:52 +01:00
Marc-André Bombeck 00d129ad42 Merge branch 'feat/v1463-restore' into release/v1.46.3 2026-10-29 20:46:23 +01:00
Marc-André Bombeck 169a0f1df4 Merge branch 'feat/v1463-labs' into release/v1.46.3
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-29 20:43:40 +01:00
Marc-André Bombeck 77a76a9e33 refactor(backup): keep the upload flow owned by the backups section
The section that renders the recent-proof dialog is the one that hands
the proof to each gated flow, which the recent-proof callers test reads
from its source. The upload hook goes back there and the upload
component takes the flow it returns.
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 8d765139d3 test(timeline): walk a lab marker through the timeline journey
The journey picks a lab marker from the values menu, sees its readings
drawn with the range behind them and the one below it as a triangle,
reads that reading's card by keyboard with its report, checks the page
with axe, reloads to find the choice kept, and on a phone finds the
readings on their lab days without a sideways scroll before unchoosing
the marker again. The mocks answer lab markers only when a test asks for
them, so the other value-line steps keep counting what they counted, and
the two values triggers (the toolbar's and the phone's) are addressed by
their surface.

Refs #1299
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 46350b6461 refactor(backup): one success line for a stored upload
Both upload paths now announce a stored copy through one function, so
the upload hook keeps the single success affordance it is pinned at,
and the upload card and target dialog are wired in their own component
rather than in the backups section, which stays under its size line.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck 8ed6eb5793 refactor(backup): publish the target codes and keep the upload handler short
The target refusals answer with eight new meta.errorCode values; they
are now constants the catalogue sweep reads and entries in the
published catalogue, so a client can branch on them without triggering
each one. The upload handler had grown past its size baseline with the
target branch, so the target checks, the file read and the schema check
move into named functions beside it, unchanged. The target check route
holds its params to a strict schema like every other route.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00
Marc-André Bombeck 42b2c72e28 feat(timeline): serve lab markers as value lines
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.

`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.

A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.

Refs #1299
2026-10-29 20:31:27 +01:00
Marc-André Bombeck 646ad3c5d8 Merge branch 'feat/v1463-codex' into release/v1.46.3 2026-10-29 20:27:15 +01:00
Marc-André Bombeck b1bbffc69b docs(backup): restoring the copy of an erased account
The runbook said a restore writes into an account that exists under the
same id, which left an operator with an erased account's file and no
way in. It now says where such a copy can go, how the new account is
signed into, why an account with records is refused, and what does not
come back.
2026-10-29 20:20:55 +01:00
Marc-André Bombeck a40bd53d45 feat(backup): offer an account when an upload's owner is gone
An upload refused with backup.owner.missing no longer ends in an error
toast. The backups console keeps the file and opens a dialog: a new
account by default, which cannot hold anything yet, or an existing one,
which the server checks as soon as it is picked and which the admin
confirms by typing its username. Managed profiles and accounts pending
deletion are not offered. The file is then sent again with that target,
and a refusal stays in the dialog beside the choice it refuses.

The new-account path says the account has no password yet and where to
set its first one. Copy in every app language.
2026-10-29 20:20:28 +01:00
Marc-André Bombeck 2220855c8d docs(ai): describe the Codex model list read and per-sign-in memory
The protocol spec now documents the /models request, the fields read
from it, the order the walk is built in, the per-sign-in cache and the
codex.models.* annotations (the codex.slug.* keys it named never existed).
The operator env comments say that CODEX_MODEL still goes first and that
CODEX_MODEL_FALLBACK_CHAIN replaces the order and skips the list.

Refs #1287
2026-10-29 20:17:48 +01:00
Marc-André Bombeck 5ab1fb782e feat(ai): pick Codex models from the account's live list, per sign-in
A fixed preference list goes stale at every retirement: when GPT-5.5
retired for ChatGPT sign-in on 2026-10-14, every model the shipped list
named was refused and Codex answered 503 until a release changed it. The
working model also sat in one slot for the whole process, so two sign-ins
on different plans overwrote each other's model and the vision gate read
whichever had answered last.

The client now reads the models the account is offered the way the Codex
CLI does (GET /backend-api/codex/models?client_version=..., same sign-in
as the responses call; shape from codex-rs/protocol/src/openai_models.rs)
and walks: CODEX_MODEL first; our preference order kept to what is
offered; the other offered models in the list's priority order, leaving
out hidden, specialist and frontier-tier entries; the rest of our order
last, so the walk never holds fewer models than before. With
CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is
not read.

The list is a hint, never a gate. The read goes through safeFetch with a
5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per
sign-in for an hour, and a failed read waits ten minutes. Any failure
leaves the static walk, one ai.codex.models_unreadable warning per sign-in
reports it, and each request that builds the walk annotates a pinned
codex.models.* shape.

The working model, refused models and the list are kept per stored
credential (a person's connection, or the operator-shared central one),
bounded by TTL and a 1000-entry LRU, with a digest of the account id so a
credential reconnected to another account starts empty. A model the list
offered that then answers model_not_found goes to the back of that
sign-in's line for six hours. When the list names a model's input
modalities, the Codex vision gate reads them; otherwise the model family
decides, as before.

Refs #1287
2026-10-29 20:17:42 +01:00
Marc-André Bombeck 8014fd4aae feat(backup): restore an erased account's copy into a chosen account
After an account is erased the operator still has its disaster-recovery
file, and the upload refused it because its owner was gone. The refusal
now carries backup.owner.missing, and the file can be sent again with a
target in the query: an existing account that holds no records yet,
confirmed by typing its username, or a new account created for the copy.
The file's owner is rewritten to that account as it is read, so the
stored copy, its preview, its download and its restore are that
account's, and the restore that follows is the ordinary one.

The new account has no password and no email; an admin sets its first
password with the existing reset. A file whose own account is still here
is refused with a target, and an account made for a refused file is
removed again. The refusal of an unknown owner no longer writes the
owner's id into the audit trail, since the erasure took every row that
named it.
2026-10-29 20:11:14 +01:00
Marc-André Bombeck 9baeb95bab feat(backup): say whether an account can take an erased account's copy
A restore replaces the account's records and has no merge, so the copy
of an account that no longer exists may only go into an account that
holds none of the records a backup carries. The check reads every
backed-up model by its userId, reaches the owner-less children through
their parent, and is held to the schema so a new model cannot be read
as empty. Managed profiles and accounts pending deletion are refused as
well. The admin console asks GET /api/admin/backups/targets/{userId}
before it sends a file again, so a refusal costs no upload.
2026-10-29 20:11:08 +01:00
Marc-André Bombeck 973bb8e5fc feat(backup): rewrite a backup file's owner as it streams
A backup names its owner once, in the top-level userId, and the restore
writes every section under that account and no other. Pointing the copy
of an erased account at another account is therefore one member, not a
walk over every section. This changes that member on the bytes' way in,
without holding the document, and reports the owner the file declared.
Nested userId values and every other byte pass through unchanged.
2026-10-29 20:11:02 +01:00
Marc-André Bombeck 1d374dd4d5 refactor(ai): move the Codex reasoning wire into its own module
The reasoning-field planning and the refusal learning are a self-contained
part of the Codex wire. Moving them out of codex-client.ts leaves room for
the per-sign-in model selection that follows without growing a file the
size ratchet already holds at its limit.

Refs #1287
2026-10-29 19:55:20 +01:00
Marc-André Bombeck b1fe1f0597 fix(ai): hold a refused reasoning field for a day
A gateway that refuses the reasoning field does so by its configuration,
so the lesson now holds for 24 hours instead of one. At the hour every
expiry cost one refused request, a few a day on a gateway that runs the
briefings unattended. Lessons read from a single reply keep the hour.

Refs #1279
2026-10-29 19:39:38 +01:00
Marc-André Bombeck f17ad1f7b3 Merge pull request #1300 from MBombeck/release/v1.46.2
Release v1.46.2
2026-10-29 12:11:46 +01:00
Marc-André Bombeck a587be009d test(backup): report a broken pipe in the dump test's docker wrapper
Under a loaded full run the wrapper around pg_dump and pg_restore crashed
with an unhandled stream error, and the dump under test reported only the
tail of that stack. The wrapper now leaves the exit code to the tool and
writes the reason to stderr.
2026-10-29 11:51:39 +01:00
Marc-André Bombeck 20585f3b3d chore(release): v1.46.2 2026-10-29 11:28:49 +01:00
Marc-André Bombeck 14eaa5c7c5 test(backup): keep the 1.46.2 backup file as a golden 2026-10-29 11:28:48 +01:00
Marc-André Bombeck d1ea803260 Merge branch 'fix/v1462-review' into release/v1.46.2 2026-10-29 11:27:58 +01:00
Marc-André Bombeck 34f8a856b4 fix(labs): register the lab repair's undecryptable count as a job fact
The count rides out in the job's outcome facts, whose vocabulary is an
allowlist, and is kept as a failure tally so the catch that feeds it is
not a silent one.
2026-10-29 11:16:18 +01:00
Marc-André Bombeck 6b820fa3c5 fix(rollups): defer the dense fold when the backfill check cannot read the queue
The check whether an account's full rollup backfill is queued or
running answered false on any error, so a failed read let the dense
fold delete raw samples the backfill still had to read. A missing queue
table still answers false, since a web-only deployment has no backfill;
any other failure now counts as pending and is annotated. A backfill
that failed for good is still not pending, as the comment now says.
2026-10-29 11:05:43 +01:00
Marc-André Bombeck ec0f67f850 fix(admin): give the single-account rollup recompute the boot fold's budget
The admin recompute for one account called the full fold without a
statement budget, so on the large accounts it is meant for each yearly
statement ran under the database's ordinary limit and timed out, as the
boot job had before it got its own. It now passes the same per-statement
budget.
2026-10-29 11:04:21 +01:00
Marc-André Bombeck 6fb50b01b8 fix(auth): offer the provider re-proof to a browser only
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
2026-10-29 11:03:09 +01:00
Marc-André Bombeck fc117d7cd9 fix(auth): date a provider re-proof by its auth_time
The re-proof accepted an auth_time up to five minutes old and then
stamped the session at callback time, so the proof held for close to
ten minutes after the person signed in at the provider. It now stamps
the authentication instant, clamped to now for a provider clock that
runs ahead, and the gates' five-minute window runs from there.
2026-10-29 11:01:25 +01:00
Marc-André Bombeck 6df0fad3b7 fix(labs): let the lab date repair skip a damaged fact and read the app's zone
One staged fact that would not decrypt threw out of the whole repair, so
the job failed, retried, and was enqueued again on every boot. It is now
skipped and counted as undecryptable.

The candidate query read an account zone Postgres does not know as UTC,
while the repair resolves it to the instance default and then
Europe/Berlin. A value confirmed near midnight stayed a candidate the
repair always left alone, so the set never converged. The query now
falls back the same way.
2026-10-29 11:00:01 +01:00
Marc-André Bombeck a6f4c386fe fix(jobs): judge a system job's failure streak per job, per account
The alert added every attempt of a failed job to the streak, so one job
with two retries read as three failures. It also judged a queue by its
newest job across all accounts: on a queue that runs one job per
account, one account failing every hour beside accounts that succeed
never reached the streak, or flipped between failing and recovered.

The streak now counts jobs, and is kept per account (the job's userId,
or the whole queue for jobs without one). A queue is failing while any
account has failed three jobs in a row, and recovered only when a
failing account has itself run successfully since. A reminder needs a
failure newer than the notice before it, so a one-off job that failed
and does not run again is announced once.
2026-10-29 10:58:04 +01:00
Marc-André Bombeck ec2ec40003 fix(measurements): report folded provider heart rate to the change feed
The dense fold deleted Google Health and Oura samples outright, on the
reasoning that no client pulls them. The change feed serves every
source, so a client mirroring it kept those samples next to the hourly
means with nothing to tell it they were gone.

The nightly increment of a server-pulled source is tombstoned again: the
feed reports each sample and the tombstone cleanup purges them after the
retention. A backlog day, such as an account's first fold, is still
deleted outright, and the account's delta cursors expire instead, since
tombstoning millions of rows would undo the point of the fold. The
compaction-tombstone class leaves these sources out, so the feed, the
backlog purge and the restore treat their tombstones like any deletion.
Apple Health and Health Connect are unchanged.
2026-10-29 10:52:54 +01:00
Marc-André Bombeck dba031d4ad test(integration): record the release's own behaviour in the goldens
The status prompts carry the unattended flag for background calls, the
queue list holds the lab date repair, and an account whose only way to
confirm is its identity provider is now offered that way.
2026-10-29 10:42:40 +01:00
Marc-André Bombeck 17d9c15dbc fix(measurements): keep a folded hour out of a blood-pressure reading
The reading delete found the cuff's pulse by owner, source and identical
instant. The dense fold anchors an hour of heart rate at the local HH:30
under the samples' source, so a reading taken at half past took that hour
with it. Aggregate (stats:) rows are never part of a reading now, for the
delete and for the restore.
2026-10-29 10:34:01 +01:00
Marc-André Bombeck ad5d154561 Merge branch 'feat/v1462-scale' into release/v1.46.2
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-29 10:18:22 +01:00
Marc-André Bombeck a9c2e25099 fix(rollups): trail the rollup window from the caller's moment
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
2026-10-29 10:13:22 +01:00
Marc-André Bombeck 1525be5d53 Merge branch 'feat/v1462-tests' into release/v1.46.2 2026-10-29 10:04:42 +01:00
Marc-André Bombeck f24589c294 test(integration): take a large Google Health account through the fixes
420 000 heart-rate readings, one every five minutes over about four
years, written through the real Google Health backfill with Google's
API faked at the safeFetch seam. The account's DAY rollups lose their
reading extremes, as rows folded before 0417 have them; the boot
discovery finds it, and the windowed full backfill refills every bucket
in the fold window with the raw readings' own extremes. The dense fold
then thins everything older than 90 days to hourly Google Health rows
(420 000 rows to about 59 000) while the DAY tier keeps its figures,
and a second full-history backfill over the same samples inserts
nothing. Without the ingest guard it re-inserts about 394 000 of them.

Each step reports its duration on stderr. On a developer machine:
backfill about 85 s, discovery 0.2 s, full backfill 4 s, fold 40 s,
second backfill 4 s.
2026-10-29 10:03:49 +01:00
Marc-André Bombeck 66081e7102 test(backup): restore the backup of an account that was erased
Nothing covered the operator's position after an erasure: a backup file,
an admin session, and a server that no longer knows the account. Export
the shared backup seed, delete the account through the settings route
under a 14-day window and run the purge job past its end, then:

- scan every text and JSON column for the account's id; only a
  rate-limit bucket that expires within a day may remain;
- upload the file as an admin: refused as owner_not_found, nothing
  stored (the documented contract);
- recreate the account row under its id, upload and restore: every
  section's count matches the export;
- point the file at a freshly created account, upload and restore: every
  count matches and nothing names the erased account afterwards.
2026-10-29 10:03:10 +01:00
Marc-André Bombeck 9d27b6f103 test(backup): restore every release's own backup file
Every backup test exports and restores with the same code, so a change
that moves both ends at once passes all of them and still breaks a file
a self-hoster downloaded last month. Commit the disaster-recovery file
the 1.46.1 exporter writes for the shared backup seed, with a manifest of
its counts and SHA-256, and restore each committed file through the admin
upload and restore routes into an empty account, comparing the re-exported
counts section by section.

The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the
current one; the README says when). The seed's own illness-symptom
catalogue key is swapped for a shipped one so every link resolves on a
fresh instance. Ciphertext is sealed under the suite's fixed key. A
measurement tombstone that ages past retention may only lower its count.
2026-10-29 09:58:20 +01:00
Marc-André Bombeck 1340200cac test(e2e): hold the header height in every app language
A translated label that runs a little longer than the English one wraps,
and a wrapping header grows; every English-only check stays green while
one locale ships a taller dashboard header. The new sweep switches its own
account through each locale the app ships (via the locale API, with
<html lang> asserted before measuring), then compares the top bar and the
dashboard's page-header row on a 390 px phone and a desktop window with
the height English gives them, within 1 px.

The locale list is read from the i18n config, so a new language is swept
without editing the spec. It runs in the desktop project only because the
account's language is one value shared by its tests.
2026-10-29 09:58:14 +01:00
Marc-André Bombeck f74fcf88b1 docs(self-hosting): add a section on large accounts
What an account with tens of millions of readings looks like when it
runs into the per-statement limit (empty cards, job.run.failed with
57014), what the full backfill, the hourly heart-rate fold and the
admin alert now do about it, and when raising
DATABASE_STATEMENT_TIMEOUT_MS still makes sense. The variable was only
named in the compose file and the example env until now (#1298).
2026-10-29 09:54:52 +01:00
Marc-André Bombeck f99f87eca6 feat(jobs): tell admins when a system job keeps failing
A full rollup backfill that failed on every boot showed only on the
admin page's card of failing background jobs. From the account's side
the only symptom was empty dashboard cards, and nothing pointed the
operator at the card (#1298).

A new hourly evaluation reads the newest terminal runs of every queue
whose failure has no account-facing surface (the system audience in
subsystem-surface.ts) from pg-boss's own ledger. A failed run adds its
attempts, retries included, and the first successful run ends the
streak. Three failed attempts in a row send every admin a SYSTEM_ALERT
once, a reminder at most once a day while the queue keeps failing, and
one notice when it runs successfully again: the decision the backup
health notices use, with the same compare-and-set claim, so two workers
never send a notice twice.

The memory is one row per announced queue in system_job_alert_notices
(migration 0490). The notice names the queue, the streak and the last
error, and opens the admin status page.
2026-10-29 09:54:43 +01:00
Marc-André Bombeck d0be3ae106 Merge branch 'feat/v1462-auth' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck 4a99658632 Merge branch 'feat/v1462-data' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck a571dcc47a refactor(measurements): drop a fold-source helper nothing calls
The ingest guard reads the per-source map through the folded-window rules, so the standalone predicate had no caller.
2026-10-29 09:43:37 +01:00
Marc-André Bombeck f92889ab88 refactor(auth): keep the account payload handler and chat schemas in size
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
2026-10-29 09:42:31 +01:00
Marc-André Bombeck 07cf9bba3e fix(measurements): hold the dense fold back while an account's rollups rebuild
The full rollup backfill refills each day's figures from the raw
readings, and the dense fold deletes those readings once it has their
hourly means. On a large account the backfill can run longer than the
ten minutes the fold's boot job waits, and a year it reached after the
fold had taken it would compute its days from hourly means: the day
count, minimum and maximum of every folded day would change.

The consolidation walk now asks a per-account deferUser hook before it
reads an account, and both entries of the dense fold (the per-account
boot job and the nightly drain) pass isRollupFullBackfillPending, which
looks for a queued or running full fold of the account in pg-boss. The
account is folded on a later run, once its rollup tier is rebuilt.
2026-10-29 09:28:31 +01:00
Marc-André Bombeck d7409b38d7 fix(i18n): address the French reader as "vous" throughout
The French bundle mixed the familiar and the formal address: about a
third of the strings said "tu", "ton" or "Réessaie", the rest "vous",
sometimes both on one screen and once in one string.

Every string that addressed the reader as "tu" now uses "vous" with the
matching conjugation, possessives and agreement; placeholders, plural
branches and punctuation are unchanged. The Coach prompts a person
sends address the Coach the same way, the casual "Salut" greeting of
the nudges becomes "Bonjour", and "après que" takes the indicative.
2026-10-29 09:22:57 +01:00
Marc-André Bombeck 919d64947c feat(mental-health): name the Dutch and Belgian crisis lines
A Dutch reader whose screener answer raised the safety path saw only
the international set, because no Dutch set existed.

The Netherlands gets 113 Zelfmoordpreventie (113, the free 0800-0113
and the chat on 113.nl), Belgium the Zelfmoordlijn (1813,
zelfmoord1813.be), both behind 112. The app's one Dutch bundle is read
in both countries and the account keeps no country, so a bare "nl"
lands on a combined set that names each line with its country, the way
the German and Portuguese sets already do; an explicit nl-NL, nl-BE or
region "be" gets its own country's set.
2026-10-29 09:22:00 +01:00
Marc-André Bombeck 6f60812132 fix(ai): accept every app language on the chat and status requests
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.

All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
2026-10-29 09:19:56 +01:00
Marc-André Bombeck 65fe5c34e3 fix(dashboard): welcome a new account instead of welcoming it back
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.

/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
2026-10-29 09:13:48 +01:00
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00
Marc-André Bombeck ef141f457b test(i18n): lower the version-stamp baseline for the provider
The locale effect and its dated comment left the i18n provider with the
previous commit, so the ratchet records one stamp fewer.
2026-10-29 09:09:35 +01:00
haritowa 0b32b567c0 docs(ai): record the live check of the GPT-6 rungs and the retired 5.x line
From a HealthLog container with its stored ChatGPT sign-in, gpt-6.1-sol,
gpt-6-sol and gpt-6-luna each answered text and read a lab-report image,
and the account's own model list offered them with image input.
gpt-6.1-sol refuses effort none and names low as its lowest. The spec said
the gpt-6 rungs had never been probed live, and still listed gpt-5.4,
gpt-5.4 mini, gpt-5.3-codex and gpt-5.2 as accepted, all of which OpenAI
has retired for ChatGPT sign-in, with the exact refusal the backend sends.

Also note why a fixed chain goes stale: once GPT-5.5 retired, every model
on the chain shipped at the time was retired with it.
2026-10-29 09:09:23 +01:00
haritowa 69d46f0add test(ai): pin the walk past a retired model on the default Codex list
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".

Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
2026-10-29 09:09:02 +01:00
haritowa acf58923f7 test(ai): pin the step-up when gpt-6.1-sol refuses effort none
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
2026-10-29 09:08:32 +01:00
Marc-André Bombeck bf0142a626 fix(ai): give background work the High room on a model that always reasons
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.

The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.

The room calculation both clients shared is now one function.

Reported by @Tio61 in #1276.
2026-10-29 09:07:37 +01:00
Marc-André Bombeck 9eed407ccc feat(measurements): fold Google Health and Oura heart rate like the exports
The dense hourly fold only took Apple Health and Health Connect samples,
so heart rate from the provider syncs that store one row per sample
stayed raw forever. One self-hosted account held about 25 million
Google Health pulse rows after a full-history backfill, and every
reader and fold that falls back to the raw table paid for it (#1297).

The fold now walks a map of sources to the dense types each stores
sample by sample: every dense type for Apple Health and Health Connect,
heart rate for Google Health and Oura. Their daily SpO2 values are no
intraday stream and stay as they are; Fitbit does not sync intraday
heart rate, and WHOOP, Withings and Polar write dense types only once
per recovery, night or spot check. Each source folds into its own
hourly stats rows, and the DAY rollup is still taken from the raw
samples before they go.

The fold deletes the samples it averages, and the provider syncs write
first-write-wins on the externalId, so a backfill or full sync would
have put every folded sample back. Google Health would even have
re-keyed an hourly row into a sample that fell on its anchor through
the natural-key rescue. The folded_window rule now has one hour rule
per fold source, and both syncs drop the samples of an hour the fold
already took before they write. Recent samples cost no lookup.

The boot discovery of the fold probes each account and source for the
first raw sample it would take instead of grouping every such row, and
the one-time fold repair stays on the two sources it was written for.
2026-10-29 09:06:17 +01:00
Marc-André Bombeck 2490a2d218 fix(i18n): mirror the language to the account only when signed in
The provider sent PUT /api/auth/me/locale on mount and on every switch,
signed in or not. Signed out the route answers 401, the once-per-tab
marker was never set, and every signed-out page load repeated the
refused write.

The mirror moves out of the provider into LocaleServerSyncBridge, which
watches the account payload without fetching it and writes only once an
account is known. The choice made on the sign-in page therefore reaches
the account right after signing in, and the marker is keyed on the
account as well as the language, so another account signing in on the
same tab is written for too. The browser test stub no longer treats the
PUT as ambient, which is what lets its test see a stray write.
2026-10-29 09:00:04 +01:00
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00
Marc-André Bombeck e2998183c8 fix(measurements): delete a blood-pressure reading as one
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.

A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.

Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
2026-10-29 08:56:19 +01:00
Marc-André Bombeck 1d8e2e0ee3 fix(rollups): fold the full backfill a year at a time under its own budget
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).

The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.

The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
2026-10-29 08:52:36 +01:00
Marc-André Bombeck d88313e929 fix(labs): move document lab values filed under their confirmation day
Before the confirm step dated an undated value by its document, a lab
value confirmed from a report that prints its collection date only in the
header was stored with takenAt set to the moment of confirmation. The fix
covers new confirms; the values already filed that way stay on the wrong
day until something moves them.

A one-shot maintenance job, sent at boot only while there is something to
repair, moves exactly those values to the document's report date (else its
filing date) at noon UTC, the same rule the confirm step now uses. A value
qualifies only when the chain is certain: written by the document confirm
path, named by exactly one approved observation fact, takenAt still within
five seconds of its own createdAt, no calendar date of its own in the
decrypted fact, and a document day that differs from the day the value is
filed under in the owner's zone. A value re-dated since, one really taken
on its confirmation day, or one whose document has no date is left alone.
A moved value no longer sits at its creation moment, so the pass converges
and a second run changes nothing.

The job definition lives in a new registry file beside maintenance.ts so
that file stays under the size ratchet.
2026-10-29 08:49:40 +01:00
Marc-André Bombeck cb80296a2a Merge pull request #1293 from haritowa/documents-fact-date-from-document 2026-10-29 08:37:56 +01:00
Marc-André Bombeck 35a0ceb12c Merge pull request #1294 from MBombeck/fix/throttle-test-clock
fix(auth): read a remembered browser's expiry on the throttle's clock
2026-10-26 15:36:37 +01:00
Marc-André Bombeck b180d09fae fix(auth): read a remembered browser's expiry on the throttle's clock
knownSignInSource compared a trusted device's expiry with new Date()
while the throttle around it times its waits with Date.now(). The test
mocks Date.now() only, so it set the device to expire 30 days after its
fixed clock, which the real date passed on 2026-10-26 at noon UTC; from
then on the remembered browser read as expired and the test failed on
every run. One clock decides both now, and the test's expiry is fixed
against it. A further case pins that an expired device gets no
exemption.
2026-10-26 15:08:06 +01:00
Marc-André Bombeck 460a4fa8f9 Merge pull request #1291 from MBombeck/release/v1.46.1
Release v1.46.1
2026-10-26 08:28:30 +01:00
Marc-André Bombeck 444ff66086 refactor(charts): decide in the overlays hook where a chart's labels go
The narrow-chart rule lived in HealthChart's body and grew it past its
size baseline. The overlays hook, which already sizes the room beside
the plot, now takes the chart's width, keeps no room on a narrow chart
and marks each target zone whose label sits inside it; the x axis
helper works out its own tick layout. The tiered series unit test mocks
the lookup for readings older than the rollups and covers the head the
read puts in front of them.
2026-10-26 07:59:58 +01:00
Marc-André Bombeck f4cb15d823 chore(release): v1.46.1 2026-10-26 07:47:25 +01:00
Marc-André Bombeck 13860422cf fix(series): show a record's history older than the rollups on the All range
A range longer than a year reads its series from the week or month
rollups, which are kept for the last five years. When the rollups held
any rows the read returned them alone, so on a record that goes back
further the All range started where the rollups start: a weight history
from 2015 charted from late 2021. The readings older than the first
rollup day are folded live at the tier's grain and lead the series; the
head ends where the first tier bucket begins, so no month is counted
twice. One indexed lookup when there are none.
2026-10-26 07:46:50 +01:00
Marc-André Bombeck 467d5dcb48 fix(charts): give the plot its width back on a phone
1.46 moved the average's and the target zones' labels beside the plot,
in a right margin sized to the longest label. On a phone that margin
took about a third of the plot's width, and the legend, wrapping into
the narrower space, took more of its height: a pulse chart drew at
160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside
the plot again, the average's at the top left as before and a zone's
at the top right of its band, and the plot keeps no room beside it.

The x ticks assumed an 80 px date label. A 90-day chart's month starts
sit about 78 px apart on a phone, so the month grid never fit and the
axis showed one date. The label width now comes from a date in the
reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled
with the year alone, narrow enough for one tick a year, and with the
year it marks when its point falls in the December before.

The y axis shows the decimals its tick step needs: 57, 58, 59 bpm
instead of 57.0, 58.0, 59.0.
2026-10-26 07:42:27 +01:00
dependabot[bot] 0497d60a89 build(deps): bump docker/login-action from 4.6.0 to 4.7.0 (#1289)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 01:32:51 +00:00
haritowa 4ca7573f90 test(documents): pin an undated lab value's date through the confirm route
Against a real Postgres and the real confirm route: an undated value takes
the report date over the filing date, a value's own date is kept, an
impossible own date counts as none, and a document without a report date
dates the value by its filing date.
2026-10-26 01:08:43 +00:00
haritowa 58141cb48b fix(documents): date an undated lab value by its document
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.

The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.

A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
2026-10-26 01:08:43 +00:00
1486 changed files with 84795 additions and 11917 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"
+22 -10
View File
@@ -375,11 +375,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# per-user BYO keys keep the pinned public-host posture either way.
# ADMIN_AI_BASE_URL_ALLOWLIST=""
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
# (comma-separated, first match wins). Reach for these when the provider
# rotates a slug and requests start failing with 503 -- without them a
# rotation needs a new release to fix.
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
# the models its account is offered (read from the provider the way the Codex
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
# (comma-separated, first match wins) and the account's list is then not read.
# CODEX_MODEL=""
# CODEX_MODEL_FALLBACK_CHAIN=""
@@ -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"
+2 -2
View File
@@ -150,7 +150,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -350,7 +350,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+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
+14 -4
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
@@ -113,7 +113,7 @@ jobs:
# this covers `linux/amd64`. The arm64 manifest shares every
# application layer and differs only in native binaries.
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -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
+5
View File
@@ -30,6 +30,11 @@ scripts/bench/results
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
-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
+557 -1
View File
@@ -1,5 +1,561 @@
# 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
- **Codex picks from what the account is offered.** OpenAI retires Codex
models for ChatGPT sign-in in steps, and every retirement left Codex
refusing every request until a release changed the built-in list. Each
sign-in now reads its account's model list, the way the Codex CLI does,
and chooses from it in HealthLog's order of preference. Whether a model
reads images comes from the same list. If the list can't be read, the
built-in order applies, and `CODEX_MODEL` and
`CODEX_MODEL_FALLBACK_CHAIN` keep their meaning. The working model is
remembered per sign-in instead of in one slot for the whole server, so
two accounts on different plans, or the shared central connection, no
longer overwrite each other, and a sign-in reconnected to another
account starts fresh. (#1287)
- **A first Google Health sync no longer stalls on the sleep history.** Each
night's clean-up of re-scored sleep carried every segment id of the whole
fetch, so a backfill of a few years sent one query per night with tens of
thousands of values, over the database's limit, and kept the server busy
building them for many minutes before each one failed with a warning. Each
night now protects only the segments inside it, which is all the clean-up
can reach. (#1195)
- **A gateway that refuses the reasoning field is asked without it for a
day.** The lesson held for an hour, and every expiry cost one refused
request, a few a day on a gateway that runs the briefings unattended.
Lessons read from a single reply keep the hour. (#1279)
### Added
- **Lab values on the timeline.** The values menu lists the lab markers
the record has, and a chosen marker draws its readings as their own
points, never averaged, with the reference range behind them. A reading
outside the range is drawn as a triangle and named in words. A tap shows
the day, the value, the range and the report it came from. Readings in a
unit that cannot be converted are left out and the timeline says how
many. Up to 24 markers at once; the choice is remembered like the other
value lines. (#1299)
- **Restoring the backup of an erased account.** An upload whose account
no longer exists used to be refused with nowhere to go. Admin, Backups
now offers to restore it into a new account created for it, or into an
existing account that holds no records beyond what sign-up and setup
leave, confirmed by typing its username. Admin accounts are not offered.
Nothing names the erased account afterwards. A new account has no
password until an admin sets one with Reset password. Restoring the same
file into a second account is refused with an explanation instead of a
generic failure. The runbook in `docs/ops/backup-restore.md` has the
details.
### Thanks
@haritowa for taking the Codex retirements apart in #1287 and proposing
the live list. @mills1975 for the timeline idea in #1299. @Tio61 for three
days of request counts that showed what the hour still cost (#1279).
## [1.46.2] — 2026-10-29
### Fixed
- **Large accounts get their summaries.** On an account with tens of
millions of heart-rate readings the job that rebuilds the summary tier
after an upgrade ran one aggregate over five years under the database's
60 second limit, never finished, and left the dashboard cards reading the
raw table until they timed out too. It now works through the history one
calendar year at a time, every step gets five minutes of its own, and a
run that is out of time hands the remaining years to a follow-up instead
of starting over. Finding the accounts that need the rebuild no longer
reads every reading. If you raised `DATABASE_STATEMENT_TIMEOUT_MS` as a
workaround, you can set it back. (#1298)
- **Lab values from a document land on the day of the report.** A report
usually prints the collection date once, in its header, so confirming it
filed every undated value under the day you confirmed it. An undated
value now takes the report date, or the filing date when the report
names none, and an impossible date such as 30 February counts as no date.
Values filed under the wrong day before are moved once after the update:
only those that came from a confirmed document, had no date of their own,
still sit at the moment they were confirmed, and whose document names a
different day. The server log says how many moved. (#1293)
- **Deleting a blood pressure reading deletes all of it.** Deleting the
systolic or the diastolic value used to leave the other half and the
pulse behind. It now removes the whole reading, Undo brings the whole
reading back, and deleting only the pulse still removes only the pulse.
- **Daily briefings work with models that always reason.** Background work
asks for medium reasoning at most, and a model that cannot be told to
stop thinking used all of that room and returned nothing. Once HealthLog
has seen a model answer with nothing but its reasoning, background work
gives it the room the Coach gets on High. (#1276)
- **Single sign-on accounts can confirm it's you.** Creating a token,
connecting an assistant or resetting a password asks for a fresh
confirmation, and an account with no password, passkey or second factor
had nothing to offer. It now confirms through its identity provider with
a fresh login, accepted only for the linked identity and a sign-in from
the last few minutes. The same confirmation can add a passkey. (#1296)
- **A personal baseline no longer depends on the wall clock.** Reads that
ask for a baseline as of a given moment saw a different window through
the summary tier than through the raw readings.
- Signed out, the language switch keeps your choice in the browser instead
of asking the server to store it.
- A new account is greeted with a welcome instead of "welcome back".
- The Coach chat, the document chat and the profile settings accept every
language the app ships.
- The French translation says "vous" throughout.
### Added
- **Admins hear about a background job that keeps failing.** After three
failed runs in a row every admin gets a push on their system alert
channels, a reminder at most once a day while it keeps failing, and one
message when it works again. Retries of one run count once, and a job
that runs per account counts per account, so one account in trouble is
reported once and not every hour. (#1298)
- **Heart rate from Google Health and Oura is folded like Apple Health's.**
Readings older than 90 days become one value per hour, and a later sync
does not bring the folded readings back. The first fold of a large
history works through the backlog over a few nights and waits until that
account's summaries are rebuilt; afterwards the index rebuild under
Admin, System status gives the space back. (#1297)
- **Admins can delete another account** from the users list, through the
same grace window as deleting your own. Your own account, the last admin
and managed profiles are excluded.
- Crisis lines for the Netherlands and Belgium on the support card.
- A section on large accounts in the self-hosting guide.
- Tests that restore every release's own backup file, restore the backup
of an erased account, and hold the header height in every app language.
### Thanks
@Cnote43 for #1297 and #1298, written up so precisely that the fix mostly
followed the report. @haritowa for finding, fixing and live-testing the lab
date bug (#1293) and for the Codex model checks and tests (#1288). @Tio61
for testing every step of #1276. @sposschip for #1296.
## [1.46.1] — 2026-10-26
### Fixed
- **Charts on a phone are their old size again.** 1.46 moved the labels
for your usual level and for a target zone out of the chart and beside
it. On a phone that room took about a third of the chart's width, and
the legend took more of its height. On a narrow screen the labels sit
inside the chart again, as they did before; a tablet or a wide window
keeps them beside it.
- **The date axis shows more than one date on a phone.** A 90-day chart
is labelled with the first of each month again, and the All range with
the years.
- **The value axis writes whole numbers when its steps are whole**: 57,
58, 59 bpm rather than 57.0, 58.0, 59.0.
- **The All range shows your whole history.** Long ranges are read from
summaries the server keeps for the last five years, and readings older
than that did not appear: a weight history going back to 2015 started
in late 2021. The older readings are now read directly and lead the
chart.
## [1.46.0] — 2026-10-25
### Added
@@ -99,7 +655,7 @@
fails, the app does not start and the log says why; a move cut short is
run again from scratch on the next start. The old `pgdata` volume is
left as it was, so going back is the previous image and two lines of the
compose file. On a real 600 MB instance the move took eleven seconds.
compose file. On a real 600 MB instance the move took 29 seconds.
HealthLog keeps running on Postgres 16 for installs that bring their own
database, and the tests run on both. (discussion #1282)
- **A quicker, lighter start.** On an account with ten years of data the
+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,
+16 -8
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
@@ -347,11 +353,11 @@ x-healthlog-env: &healthlog_env
# you run a gateway this instance should trust; it widens only the admin
# setting, never the per-user BYO-key posture.
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
# separated) to replace the chain outright. Reach for these when the
# provider rotates a slug and every request starts coming back 503 —
# without them a rotation needs a new release to fix.
# Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the
# models its account is offered, with a built-in order as the fallback.
# Set CODEX_MODEL to pin one slug ahead of that, or
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
# outright; the account's list is then not read.
CODEX_MODEL: "${CODEX_MODEL:-}"
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
@@ -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
+3398 -757
View File
File diff suppressed because it is too large. Load diff
+31 -12
View File
@@ -631,10 +631,10 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. The gpt-6 slugs were not probed against a live backend when they joined the chain
- `gpt-5.5` — the default from 2026-06 until 2026-10-24
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -642,9 +642,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _
The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered.
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call:
```http
GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1
Host: chatgpt.com
Authorization: Bearer <oauth-access-token>
ChatGPT-Account-ID: <chatgpt_account_id>
originator: healthlog
```
The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch.
**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family.
**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
@@ -662,7 +676,9 @@ Every default rung must be a model family the reasoning, sampling and vision gat
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated.
**Trigger condition** (when to walk to the next slug):
@@ -676,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug.
**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
**All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings".
**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`.
**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug.
The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`.
The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`.
---
@@ -779,7 +795,10 @@ All paths relative to `openai/codex` repo:
- `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers
- `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait
- `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc.
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire)
- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…`
- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity
- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL
- `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums
- `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers
- `codex-rs/model-provider/src/auth.rs` — auth-provider selection
+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
+68 -5
View File
@@ -2,9 +2,10 @@
HealthLog keeps copies of each account so that one account can be rolled back
on this server. These copies are per account. They cannot rebuild a lost
server: a restore writes into an account that already exists here under the
same id, and refuses a copy whose account is gone. To recover a server, you
need a database dump and the encryption keys. That procedure is in
server: a restore writes into an account on this server, the one the copy
belongs to, or, when that account has been erased, one you choose (see
"Restoring an erased account"). To recover a server, you need a database dump
and the encryption keys. That procedure is in
[disaster-recovery.md](disaster-recovery.md).
## What exists
@@ -81,13 +82,75 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restor
```
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
compression) and restore it from its row. The account `<id>` must exist on this
server.
compression) and restore it from its row. When the account `<id>` no longer
exists on this server, the upload offers to put the copy into another account
(next section).
Every restore, preview and upload checks first that this server holds every
application key the copy's content was written under, and refuses with the key
id named if not. Nothing is changed by a refused restore.
### Restoring an erased account
Once an account has been erased (its deletion window ended and the hourly purge
ran), its copies on this server and in the bucket are gone, but a file you kept
still holds the record: a disaster-recovery download, an off-host object you
fetched before it expired, or the person's own full backup. Uploading it in
Admin → Backups is refused at first, because the account it names no longer
exists, and the console then asks where the records should go:
- **A new account.** Type a username. The account is created without a
password or email, as an ordinary account with the user role, and nobody
can sign into it yet. Before handing it over, set its first password with
**Reset password** in Admin → Users; the person can change it, add an email
and enrol a passkey from their settings. The account is made before the
file is read, with an `admin.user.create` audit row (`outcome: "created"`),
and removed again when the file is refused, with an `admin.user.delete` row
(`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`,
the removal did not happen and the account is left over, holding nothing:
delete it in Admin → Users.
- **An existing account**, for someone who has signed up again. Pick the
account and type its username to confirm. The account must hold no records
yet: a restore replaces an account's records and does not merge them, so an
account with even one record of any kind a copy carries is refused, with the
kinds named. What signing up and the setup questions leave behind does not
count (the setup answers and the cycle settings): the restore replaces both
with the copy's. A reading or medication entered on setup's last screen is a
record, and the account is refused until it is deleted, because the restore
would delete it too. Managed profiles, accounts pending deletion and admin
accounts are not offered: an admin account is the operator's, its settings
are read under the operator's wider AI endpoint policy, and its role reaches
every account, so another person's record does not go into it.
The file is then uploaded a second time, and its owner is changed to the chosen
account as it is read. Nothing else in the file changes. The stored copy is
then that account's copy in every respect but one: its preview, its download
and its restore are the ordinary ones, and **Restore** on its row brings
everything back under the chosen account. The one difference is that it is
stored as `RETARGETED_UPLOAD_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
restore reads the file's settings as the file's author's, never as the
operator's: an AI endpoint on a private address comes back only when this host
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
been made an admin since. The erased account's id is not stored, not in
the copy and not in the audit trail.
A copy whose own account still exists on this server can only go back into
that account; choosing a target for it is refused. The copy does not carry
what a restore never touches (below), so a returning person signs in fresh,
and the links of a managed profile to its guardians do not come back: a
restored managed profile is an ordinary account.
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
A file goes into one account only. Its records keep the ids they had in the
erased account, so once it has been restored into one account, restoring the
same file into a second account would write those ids twice. The upload still
stores the second copy, but its restore is refused with
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
either account. To move the record to another account, delete the first
account (or its records) before restoring into the second.
### What a restore does not touch
API tokens, trusted devices, step-up elevations, known devices, clinician share
+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
+76 -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
@@ -250,3 +260,68 @@ The implementation lives in `src/lib/db.ts → getConnectionBudget()`
(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the
20-slot default was chosen after measuring on a 4-CPU production
container — see the inline rationale in `src/lib/db.ts`.
## Large accounts
An account that holds tens of millions of readings, most often a
full-history Google Health backfill of minute-by-minute heart rate, runs
into the per-statement limit the app sets on every database connection:
60 seconds, from `DATABASE_STATEMENT_TIMEOUT_MS` (unset means 60000, `0`
turns the limit off).
### What it looks like
- Dashboard cards stay empty, or `/api/daily/digest` and other reads
answer 500.
- The log shows `job.run.failed` for `rollup-full-backfill` with
`consecutiveFailures` counting up, and Postgres error `57014`:
`canceling statement due to statement timeout`.
- The admin status page lists `rollup-full-backfill` under failing
background jobs, and every admin gets a push once a system job has
failed three jobs in a row (retries of one job count once; a job that runs
per account counts per account), a reminder at most once a day while it
keeps failing, and a notice when the failing job runs again. A one-off job
that failed and does not run again is announced once.
### What the app does about it
- The full rollup backfill, which runs at boot for any account whose
summary tier is incomplete (and after a release that adds a column to it),
folds the five-year window one calendar year at a time. Every statement of
it runs under its own limit of five minutes, whatever
`DATABASE_STATEMENT_TIMEOUT_MS` says, and a run that reaches its time budget
hands the remaining years to a follow-up job. The readers that fall back to
the raw readings while that tier is incomplete are fast again once it has
finished.
- Heart rate older than 90 days is folded into one mean per hour, for Apple
Health, Health Connect, Google Health and Oura alike. The DAY summaries keep
the figures taken from the raw samples, and a later backfill or full sync
does not write the folded samples back. On an account with a dense history
the first fold works through the backlog over a few nights, one day per
transaction, and leaves an account alone while its summary tier is still
being rebuilt. Postgres reuses the freed table pages for new rows; to give
the index space back to the disk, run the index rebuild of the measurement
maintenance job from Admin, System status, once the backlog is through
(`docs/ops/measurement-maintenance.md`).
### When to raise the limit
Normally never: the long background passes bring their own limits. Raise
`DATABASE_STATEMENT_TIMEOUT_MS` only when request reads still time out on a
large account after the backfill has finished (the failing-jobs card is
empty), or when something between the app and Postgres sets a lower limit.
In that case also check `SHOW statement_timeout;` as the database role
HealthLog connects with: a limit set on the role or the database, or by a
pooler, applies too. Recreate the container after changing the variable so
every connection picks it up:
```yaml
# docker-compose.yml
services:
app:
environment:
DATABASE_STATEMENT_TIMEOUT_MS: "180000" # three minutes
```
A higher limit lets one slow statement hold a pool connection longer, so
raise it in steps rather than turning it off.
+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 ({
+161
View File
@@ -0,0 +1,161 @@
/**
* The app header keeps its height in every language.
*
* A translated label that is a little longer than the English one wraps, and
* a header that wraps grows: the dashboard's title row once took a second
* line in one locale while every English check stayed green. The top bar and
* the dashboard's page-header row are measured here in every shipped locale,
* on a 390 px phone and on a desktop window, against the height English gives
* them on the same viewport.
*
* The language is switched the way the app switches it, through
* `PUT /api/auth/me/locale`, which writes the account's language and the
* locale cookie; `<html lang>` is then asserted before anything is measured,
* so a switch that silently did not happen cannot pass as "same height".
*
* The locale list is the app's own (`src/lib/i18n/config.ts`), so a language
* added there is swept without touching this file.
*/
import type { Page } from "@playwright/test";
import { locales, type Locale } from "@/lib/i18n/config";
import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
const VIEWPORTS = [
{ label: "phone 390", width: 390, height: 844 },
{ label: "desktop 1280", width: 1280, height: 800 },
] as const;
type ViewportLabel = (typeof VIEWPORTS)[number]["label"];
interface HeaderGeometry {
topBar: number;
pageHeader: number;
/** For the failure message: what wrapped, not only that something did. */
title: string;
titleHeight: number;
}
/** English first: every other locale is compared with what it measured. */
const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")];
const reference = new Map<ViewportLabel, HeaderGeometry>();
async function readHeader(page: Page): Promise<HeaderGeometry | null> {
return page.evaluate(() => {
const topBar = document.querySelector('[data-slot="top-bar"]');
const rows = document.querySelectorAll(
'main [data-slot="page-header-row"]',
);
if (!topBar || rows.length !== 1) return null;
const row = rows[0];
const title = row.querySelector("h1");
return {
topBar: topBar.getBoundingClientRect().height,
pageHeader: row.getBoundingClientRect().height,
title: title?.textContent ?? "",
titleHeight: title?.getBoundingClientRect().height ?? 0,
};
});
}
/**
* Read the header until two reads a frame apart agree. The greeting swaps to
* the personalised line after hydration; measuring mid-swap would compare a
* frame nobody keeps.
*/
async function settledHeader(page: Page): Promise<HeaderGeometry> {
await page.evaluate(() => document.fonts.ready.then(() => undefined));
let previous: HeaderGeometry | null = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const current = await readHeader(page);
if (
current &&
previous &&
current.topBar === previous.topBar &&
current.pageHeader === previous.pageHeader
) {
return current;
}
previous = current;
}
throw new Error(
`header did not settle: ${JSON.stringify(previous ?? "not found")}`,
);
}
test.describe("header height in every locale", () => {
test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH });
// The account's language is one value; the locales take turns on it.
// It runs in the desktop project only (see `playwright.config.ts`) and sets
// both viewports itself.
test.describe.configure({ mode: "serial" });
for (const locale of ORDER) {
test(`top bar and dashboard header keep the English height in ${locale}`, async ({
page,
}) => {
test.setTimeout(60_000);
const switched = await page.request.put("/api/auth/me/locale", {
data: { locale },
});
expect(switched.status(), await switched.text()).toBe(200);
for (const viewport of VIEWPORTS) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
await expect(
page.locator('main [data-slot="page-header-row"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-header-greeting"]'),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(viewport.width);
const measured = await settledHeader(page);
const at = `${locale}, ${viewport.label}`;
// A band that is not there measures 0 in every locale and "agrees".
expect(
measured.topBar,
`top bar has no height (${at})`,
).toBeGreaterThan(32);
expect(
measured.pageHeader,
`page header has no height (${at})`,
).toBeGreaterThan(20);
if (locale === "en") {
reference.set(viewport.label, measured);
continue;
}
const english = reference.get(viewport.label);
expect(english, "the English reference ran first").toBeDefined();
const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`;
expect(
Math.abs(measured.topBar - english!.topBar),
`top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`,
).toBeLessThanOrEqual(1);
expect(
Math.abs(measured.pageHeader - english!.pageHeader),
`dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`,
).toBeLessThanOrEqual(1);
}
});
}
});
+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");
+49
View File
@@ -325,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScoreHistory.json",
);
/**
* The header-height sweep's own account (`header-height-locales.spec.ts`).
*
* It moves the account's language through every shipped locale; the server
* honours `User.locale` wherever a request carries no locale cookie, so on a
* shared account a parallel spec could render in Korean halfway through.
*/
export const E2E_HEADER_LOCALES = {
email: "e2e-header-locales@healthlog.test",
username: "e2e-header-locales",
password: "Hl8!Tq2vNx6mWr4Z",
role: "USER",
} as const;
export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateHeaderLocales.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -1102,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The header-height sweep's account. Default modules and layout, so the
// dashboard header carries what a fresh account's does; the language is
// put back to English because the sweep leaves it on its last locale.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json, locale)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en')
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL,
locale = 'en'`,
[
cuid(),
E2E_HEADER_LOCALES.username,
E2E_HEADER_LOCALES.email,
await hashPassword(E2E_HEADER_LOCALES.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1784,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
// The header-height sweep's jar — see `E2E_HEADER_LOCALES`.
await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+192 -1
View File
@@ -15,12 +15,19 @@
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
* 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
* assertion is addressed to a stable `data-*` attribute. The journey moves
* its account's module switch, so it runs serially and in one project.
*/
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -298,7 +305,9 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
// The picker is one button that counts the lines; the chosen ones are
// not listed beside it.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
const trigger = page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
);
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
@@ -811,6 +820,188 @@ test("a value point reads on its own, by pointer and by keyboard", async ({
await expect(tip).toHaveAttribute("data-series", series!);
});
test("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { labs: 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-labs"]'),
).toBeVisible();
await expect(menu.getByText("Eisen")).toBeVisible();
await menu
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
// The menu hands focus back to its trigger once its exit animation ends.
await expect(menu).toBeHidden();
await expect(
page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
),
).toBeFocused();
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
// Below its range: a triangle, never only a colour.
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
1,
);
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"lab:bm-ferritin",
);
// The reading below its range, by keyboard: day, value, verdict, report.
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
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");
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 marker again.
await page.reload();
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
timeout: 20_000,
});
// On a phone: the same line, listed on its lab days, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
await expect(labs.first()).toBeVisible();
await expect(labs).toHaveCount(4);
await expect(labs.last()).toContainText("22 ng/mL");
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Unchosen, it is gone from the request and the page.
await page
.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
)
.click();
await page
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
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,
}) => {
+180 -2
View File
@@ -512,10 +512,150 @@ export function fullTimeline(today: string) {
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
availableSeries: FULL_AVAILABLE,
labSeries: [],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
/**
* Two lab markers of the full record (#1299): ferritin read a few times over
* the years against a 30 to 400 range, one reading below it and one read
* from a report, and HbA1c read once. Only what `values` names is sent as a
* line; the menu offers both.
*/
export const LAB_MARKERS = [
{
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Eisen",
unit: "ng/mL",
},
{
key: "lab:bm-hba1c",
biomarkerId: "bm-hba1c",
name: "HbA1c",
panel: "Zucker",
unit: "%",
},
];
function labPoint(
id: string,
date: string,
value: number,
rangeStatus: "below" | "in-range" | "above" | "unknown",
report: { id: string; title: string } | null = null,
) {
const ranged = rangeStatus !== "unknown";
return {
id,
date,
value,
sourceValue: null,
rangeStatus,
referenceLow: ranged ? 30 : null,
referenceHigh: ranged ? 400 : null,
report: report ? { ...report, href: `/documents?doc=${report.id}` } : null,
};
}
export const LAB_LINES = [
{
...LAB_MARKERS[0],
band: { low: 30, high: 400 },
points: [
labPoint("fe-1", "2021-03-15", 22, "below", {
id: "doc-lab-1",
title: "Laborbefund März",
}),
labPoint("fe-2", "2022-09-20", 64, "in-range"),
labPoint("fe-3", "2024-02-12", 118, "in-range"),
labPoint("fe-4", "2025-11-04", 96, "in-range"),
],
excluded: 0,
},
{
...LAB_MARKERS[1],
band: null,
points: [labPoint("a1c-1", "2025-11-04", 5.4, "unknown")],
excluded: 0,
},
];
/** The lab days behind the lab lines, as the labs lane names them. */
function labsLane(keys: readonly string[]) {
const days = new Map<string, string[]>();
for (const line of LAB_LINES) {
if (!keys.includes(line.key)) continue;
for (const p of line.points) {
days.set(p.date, [...(days.get(p.date) ?? []), line.name]);
}
}
return {
key: "labs",
items: [...days]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([date, names]) =>
item({
id: `lab-day:${date}`,
kind: "labDay",
start: date,
label: names.join(", "),
sub: String(names.length),
href: "/labs",
}),
),
};
}
/**
* 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",
@@ -637,6 +777,10 @@ export function readyTimeline(today: string) {
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
labSeries: [],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [],
};
}
@@ -834,7 +978,14 @@ export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
options: { onlyOld?: boolean; dataFrom?: string } = {},
options: {
onlyOld?: boolean;
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: [] };
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
@@ -844,13 +995,38 @@ export async function mockTimeline(
const url = new URL(route.request().url());
log.timelineQueries.push(url.searchParams);
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
const values = (url.searchParams.get("values") ?? "")
const named = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
// A lab marker is a line of its own (`labSeries`), never a mean.
const labKeys = 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
? {
labSeries: LAB_LINES.filter((l) => labKeys.includes(l.key)),
availableLabs: LAB_MARKERS,
lanes:
labKeys.length > 0
? [...body.lanes, labsLane(labKeys)]
: body.lanes,
}
: {};
const zoom = url.searchParams.get("zoom") ?? "all";
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")) {
@@ -872,6 +1048,8 @@ export async function mockTimeline(
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
...labs,
...symptoms,
zoom,
bucket,
...(chosen
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Wiederherstellungs-Kit sichern",
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
"action": "Kit öffnen"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Willkommen.",
"welcomeNewWithName": "Willkommen, {name}."
},
"measurements": {
"title": "Messungen",
@@ -816,6 +819,7 @@
"typeHeartRateVariability": "Herzfrequenzvariabilität",
"typeRestingHeartRate": "Ruheherzfrequenz",
"typeActiveEnergyBurned": "Aktive Energie",
"typeBasalEnergyBurned": "Ruheenergie",
"typeFlightsClimbed": "Treppen",
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
"typeVo2Max": "VO₂max",
@@ -966,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",
@@ -1892,6 +1901,7 @@
"intakeHistoryColScheduled": "Geplant",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Quelle",
"intakeHistoryInjectionSite": "Injektionsstelle: {site}",
"intakeHistoryStatusTaken": "Eingenommen",
"intakeHistoryStatusSkipped": "Übersprungen",
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
@@ -4755,7 +4765,8 @@
"unitScore": "Punkte",
"unitMinutes": "Min.",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "Belastung"
},
"sleepQuality": {
"title": "Schlafqualität",
@@ -4777,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."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Passwort",
"totp": "Authenticator-Code",
"passkey": "Passkey",
"webauthn": "Sicherheitsschlüssel"
"webauthn": "Sicherheitsschlüssel",
"oidc": "Identitätsanbieter"
},
"currentPassword": "Aktuelles Passwort",
"authenticatorCode": "Authenticator-Code",
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
"verifyAndAdd": "Bestätigen und hinzufügen",
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
"verifyAndContinue": "Bestätigen und fortfahren"
"verifyAndContinue": "Bestätigen und fortfahren",
"oidcHint": "Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
"continueToProvider": "Weiter zur Anmeldung"
},
"passkeyName": "Name",
"noPasskeys": "Keine Passkeys registriert.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
"oidcDone": "Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
"oidcFailed": "Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
},
"passwordTitle": "Passwort",
"tokenNameLabel": "Token-Name",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionDone": "Löschung von {name} rückgängig gemacht",
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden"
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden",
"undoDeletionBodyAdmin": "Ein Admin hat das Konto {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"deleteUser": "Konto löschen",
"deleteUserTitle": "Das Konto {name} löschen?",
"deleteUserBodyGrace": "{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.",
"deleteUserBodyNow": "{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.",
"deleteUserConfirm": "Konto löschen",
"deleteUserScheduled": "Das Konto {name} ist gesperrt und wird am {date} gelöscht",
"deleteUserDone": "Das Konto {name} wurde gelöscht",
"deleteUserFailed": "Das Konto konnte nicht gelöscht werden"
},
"invites": {
"title": "Einladungen",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Download fehlgeschlagen",
"downloadStarted": "Download gestartet",
"uploadTitle": "Backup hochladen",
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Eine Kopie, deren Konto es auf diesem Server nicht mehr gibt, kann in ein anderes Konto gehen. Das Wiederherstellen ist ein eigener Schritt.",
"uploadButton": "Datei hochladen",
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.",
"restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
"restoreFailureRowsElsewhere": "Die Datensätze dieser Datei liegen schon in einem anderen Konto auf diesem Server: Dieselbe Datei wurde dort bereits wiederhergestellt. Eine Datei lässt sich nur in ein Konto wiederherstellen. Es wurde nichts geschrieben.",
"previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.",
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)",
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
},
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück.",
"target": {
"title": "In ein anderes Konto wiederherstellen",
"description": "{file} gehört zu einem Konto, das es auf diesem Server nicht mehr gibt. Wähle das Konto, in das die Datensätze gehen. Eine Wiederherstellung ersetzt die Datensätze eines Kontos, deshalb kann sie nur ein Konto ohne Datensätze aufnehmen. Die Datei wird dafür noch einmal hochgeladen.",
"choiceLabel": "Wohin die Datensätze gehen",
"newAccount": "Neues Konto",
"existingAccount": "Bestehendes Konto",
"usernameLabel": "Benutzername für das neue Konto",
"usernameInvalid": "3 bis 30 Buchstaben, Ziffern, _ oder -, nicht mit „managed-“ am Anfang.",
"newAccountHint": "Das Konto wird ohne Passwort und ohne E-Mail angelegt. Bevor du es übergibst, setze sein erstes Passwort unter Nutzer mit „Passwort zurücksetzen“.",
"accountLabel": "Konto",
"accountPlaceholder": "Konto wählen",
"checking": "Konto wird geprüft …",
"eligible": "Dieses Konto hat noch keine Datensätze und kann die Kopie aufnehmen.",
"confirmLabel": "Tippe {username} zum Bestätigen",
"submit": "In dieses Konto hochladen",
"submitting": "Wird hochgeladen …",
"success": "Backup für {username} gespeichert ({count} Datensätze). Stelle es über seine Zeile wieder her.",
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
"failureAdminAccount": "Das ist ein Admin-Konto. Die Datensätze einer anderen Person können nicht hinein. Wähle ein normales Konto oder lege ein neues an.",
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
"failureOwnerExists": "Das Konto, zu dem diese Datei gehört, ist auf diesem Server, und die Datei wird in dieses Konto wiederhergestellt. Lade sie noch einmal hoch, ohne ein Konto zu wählen."
}
},
"danger-zone": {
"title": "Gefahrenzone",
@@ -9243,6 +9389,13 @@
"titleFailing": "Sicherungen sind gefährdet",
"titleReminder": "Sicherungen sind weiterhin gefährdet",
"titleRecovered": "Sicherungen laufen wieder normal"
},
"systemJobAlert": {
"titleFailing": "Hintergrundjob schlägt fehl: {queue}",
"titleReminder": "Hintergrundjob schlägt weiter fehl: {queue}",
"titleRecovered": "Hintergrundjob läuft wieder: {queue}",
"bodyFailing": "Der Hintergrundjob {queue} ist {failures}-mal in Folge fehlgeschlagen und steht auf der Admin-Statusseite. Letzter Fehler: {error}",
"bodyRecovered": "Der Hintergrundjob {queue} ist wieder erfolgreich gelaufen."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Niederlande, rund um die Uhr, kostenlos, anonym, auch per Chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Beginn",
"end": "Ende",
"seriesCaption": "Mittelwerte nach Zeitraum",
"period": "Zeitraum"
"period": "Zeitraum",
"labsCaption": "Laborwerte nach Tag",
"marker": "Marker",
"date": "Datum",
"value": "Wert"
},
"selection": {
"empty": "Keine Einträge in diesem Zeitraum",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 Laborwert",
"labValuesFew": "{count} Laborwerte",
"labValuesOther": "{count} Laborwerte"
},
"labs": {
"menuTitle": "Laborwerte",
"otherPanel": "Weitere",
"full": "Bis zu {max} Laborwerte gleichzeitig. Entferne einen, um einen anderen hinzuzufügen.",
"above": "über dem Referenzbereich",
"below": "unter dem Referenzbereich",
"reference": "Referenz {range}",
"report": "Befund: {title}",
"reportUntitled": "Befund öffnen",
"excludedOne": "{name}: {count} Wert in einer anderen Einheit ist nicht eingezeichnet",
"excludedFew": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet",
"excludedOther": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet"
},
"legendLabValue": "Laborwert, wie gemessen",
"legendLabOutside": "Außerhalb des Referenzbereichs",
"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": {
@@ -14016,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."
}
}
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Save your recovery kit",
"body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.",
"action": "Open the kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Welcome.",
"welcomeNewWithName": "Welcome, {name}."
},
"measurements": {
"title": "Measurements",
@@ -816,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",
@@ -966,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",
@@ -1892,6 +1901,7 @@
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryInjectionSite": "Injection site: {site}",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
@@ -4755,7 +4765,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "load"
},
"sleepQuality": {
"title": "Sleep quality",
@@ -4777,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."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Password",
"totp": "Authenticator code",
"passkey": "Passkey",
"webauthn": "Security key"
"webauthn": "Security key",
"oidc": "Identity provider"
},
"currentPassword": "Current password",
"authenticatorCode": "Authenticator code",
"authenticatorPlaceholder": "6-digit authenticator code",
"verifyAndAdd": "Verify and add",
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
"verifyAndContinue": "Verify and continue"
"verifyAndContinue": "Verify and continue",
"oidcHint": "You sign in once more at your identity provider and come straight back to this page. Then do what you were about to do again.",
"continueToProvider": "Continue to sign-in"
},
"passkeyName": "Name",
"noPasskeys": "No passkeys registered.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "This shares or exports your health data. Confirm it's you to continue.",
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
"failed": "That did not work. Check what you entered and try again."
"failed": "That did not work. Check what you entered and try again.",
"oidcDone": "Confirmed. For the next five minutes you can go ahead without being asked again.",
"oidcFailed": "Your identity provider could not confirm it's you. Try again."
},
"passwordTitle": "Password",
"tokenNameLabel": "Token name",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
"undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again. Its share links and tokens stay revoked and have to be set up again.",
"undoDeletionDone": "Deletion undone for {name}",
"undoDeletionFailed": "Could not undo the deletion"
"undoDeletionFailed": "Could not undo the deletion",
"undoDeletionBodyAdmin": "An admin deleted the account {name}, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
"deleteUser": "Delete account",
"deleteUserTitle": "Delete the account {name}?",
"deleteUserBodyGrace": "{name} is signed out everywhere at once and can no longer sign in. The account and everything in it is erased on {date}. Until then you can undo the deletion here.",
"deleteUserBodyNow": "{name} and everything in the account is erased right away. This server keeps no grace window, so the deletion cannot be undone.",
"deleteUserConfirm": "Delete account",
"deleteUserScheduled": "The account {name} is locked and will be erased on {date}",
"deleteUserDone": "The account {name} was deleted",
"deleteUserFailed": "Could not delete the account"
},
"invites": {
"title": "Invites",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Download failed",
"downloadStarted": "Download started",
"uploadTitle": "Upload backup",
"uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.",
"uploadDescription": "Check an account copy and store it beside this server's copies. A copy whose account is no longer on this server can go into another account. Restoring is a separate step.",
"uploadButton": "Upload file",
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
"uploadSuccess": "Backup uploaded ({count} records)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.",
"restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
"restoreFailureRowsElsewhere": "The records in this file are already in another account on this server: the same file was restored there before. A file can be restored into one account only. Nothing was written.",
"previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.",
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)",
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "The newest dump in the bucket is from {when}.",
"offhostNeverUploaded": "No database dump has reached the bucket yet."
},
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back."
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back.",
"target": {
"title": "Restore into another account",
"description": "{file} belongs to an account that is no longer on this server. Choose the account its records go into. A restore replaces an account's records, so only an account without records can take them. The file is uploaded once more.",
"choiceLabel": "Where the records go",
"newAccount": "New account",
"existingAccount": "Existing account",
"usernameLabel": "Username for the new account",
"usernameInvalid": "3 to 30 letters, digits, _ or -, not starting with \"managed-\".",
"newAccountHint": "The account is created without a password or email. Before you hand it over, set its first password with Reset password under Users.",
"accountLabel": "Account",
"accountPlaceholder": "Choose an account",
"checking": "Checking the account…",
"eligible": "This account holds no records yet and can take the copy.",
"confirmLabel": "Type {username} to confirm",
"submit": "Upload into this account",
"submitting": "Uploading…",
"success": "Backup stored for {username} ({count} records). Restore it from its row.",
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
"failureAdminAccount": "This is an admin account. Another person's records cannot go into it. Choose an ordinary account or create a new one.",
"failureNotFound": "This account no longer exists.",
"failureUnconfirmed": "Type the account's username exactly to confirm.",
"failureUsernameTaken": "This username is already taken.",
"failureOwnerExists": "The account this file belongs to is on this server, and the file restores into that account. Upload it again without choosing an account."
}
},
"danger-zone": {
"title": "Danger zone",
@@ -9243,6 +9389,13 @@
"titleFailing": "Backups are at risk",
"titleReminder": "Backups are still at risk",
"titleRecovered": "Backups are back to normal"
},
"systemJobAlert": {
"titleFailing": "Background job failing: {queue}",
"titleReminder": "Background job still failing: {queue}",
"titleRecovered": "Background job working again: {queue}",
"bodyFailing": "The background job {queue} has failed {failures} times in a row and is listed on the admin status page. Last error: {error}",
"bodyRecovered": "The background job {queue} has run successfully again."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, daily 3.30 pm to 12.30 am)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Netherlands, 24/7, free, anonymous, also by chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgium, 24/7, free, anonymous)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Start",
"end": "End",
"seriesCaption": "Value means by period",
"period": "Period"
"period": "Period",
"labsCaption": "Lab values by day",
"marker": "Marker",
"date": "Date",
"value": "Value"
},
"selection": {
"empty": "No entries in this period",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 lab value",
"labValuesFew": "{count} lab values",
"labValuesOther": "{count} lab values"
},
"labs": {
"menuTitle": "Lab values",
"otherPanel": "Other",
"full": "Up to {max} lab values at once. Remove one to add another.",
"above": "above range",
"below": "below range",
"reference": "reference {range}",
"report": "Report: {title}",
"reportUntitled": "Open the report",
"excludedOne": "{name}: {count} value in another unit is not drawn",
"excludedFew": "{name}: {count} values in another unit are not drawn",
"excludedOther": "{name}: {count} values in another unit are not drawn"
},
"legendLabValue": "Lab value, as measured",
"legendLabOutside": "Outside the 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": {
@@ -14016,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."
}
}
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Guarda tu kit de recuperación",
"body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.",
"action": "Abrir el kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Te damos la bienvenida.",
"welcomeNewWithName": "Te damos la bienvenida, {name}."
},
"measurements": {
"title": "Mediciones",
@@ -816,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",
@@ -966,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",
@@ -1892,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",
@@ -4755,7 +4765,8 @@
"unitScore": "puntos",
"unitMinutes": "min",
"unitBpm": "ppm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carga"
},
"sleepQuality": {
"title": "Calidad del sueño",
@@ -4777,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."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Contraseña",
"totp": "Código del autenticador",
"passkey": "Clave de acceso",
"webauthn": "Llave de seguridad"
"webauthn": "Llave de seguridad",
"oidc": "Proveedor de identidad"
},
"currentPassword": "Contraseña actual",
"authenticatorCode": "Código del autenticador",
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
"verifyAndAdd": "Verificar y añadir",
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
"verifyAndContinue": "Verificar y continuar"
"verifyAndContinue": "Verificar y continuar",
"oidcHint": "Vuelves a iniciar sesión en tu proveedor de identidad y regresas directamente a esta página. Después repite lo que ibas a hacer.",
"continueToProvider": "Continuar al inicio de sesión"
},
"passkeyName": "Nombre",
"noPasskeys": "No hay claves de acceso registradas.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo.",
"oidcDone": "Confirmado. Durante los próximos cinco minutos puedes continuar sin que se te vuelva a preguntar.",
"oidcFailed": "Tu proveedor de identidad no pudo confirmar que eres tú. Inténtalo de nuevo."
},
"passwordTitle": "Contraseña",
"tokenNameLabel": "Nombre del token",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
"undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso. Sus enlaces compartidos y tokens siguen revocados y hay que configurarlos de nuevo.",
"undoDeletionDone": "Eliminación de {name} deshecha",
"undoDeletionFailed": "No se pudo deshacer la eliminación"
"undoDeletionFailed": "No se pudo deshacer la eliminación",
"undoDeletionBodyAdmin": "Un administrador eliminó la cuenta {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
"deleteUser": "Eliminar cuenta",
"deleteUserTitle": "¿Eliminar la cuenta {name}?",
"deleteUserBodyGrace": "{name} cierra sesión en todas partes al instante y ya no puede iniciar sesión. La cuenta y todo su contenido se borran el {date}. Hasta entonces puedes deshacer la eliminación aquí.",
"deleteUserBodyNow": "{name} y todo el contenido de la cuenta se borran de inmediato. Este servidor no tiene plazo de gracia, así que la eliminación no se puede deshacer.",
"deleteUserConfirm": "Eliminar cuenta",
"deleteUserScheduled": "La cuenta {name} está bloqueada y se borrará el {date}",
"deleteUserDone": "Se eliminó la cuenta {name}",
"deleteUserFailed": "No se pudo eliminar la cuenta"
},
"invites": {
"title": "Invitaciones",
@@ -8200,7 +8319,7 @@
"downloadFailed": "La descarga falló",
"downloadStarted": "Descarga iniciada",
"uploadTitle": "Subir copia de seguridad",
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.",
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. Una copia cuya cuenta ya no está en este servidor puede ir a otra cuenta. Restaurar es un paso aparte.",
"uploadButton": "Subir archivo",
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.",
"restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
"restoreFailureRowsElsewhere": "Los registros de este archivo ya están en otra cuenta de este servidor: el mismo archivo ya se restauró allí. Un archivo solo puede restaurarse en una cuenta. No se escribió nada.",
"previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.",
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)",
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "El volcado más reciente del bucket es del {when}.",
"offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket."
},
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven."
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven.",
"target": {
"title": "Restaurar en otra cuenta",
"description": "{file} pertenece a una cuenta que ya no está en este servidor. Elige la cuenta a la que van sus registros. Una restauración reemplaza los registros de una cuenta, así que solo una cuenta sin registros puede recibirlos. El archivo se sube una vez más.",
"choiceLabel": "Adónde van los registros",
"newAccount": "Cuenta nueva",
"existingAccount": "Cuenta existente",
"usernameLabel": "Nombre de usuario de la cuenta nueva",
"usernameInvalid": "De 3 a 30 letras, números, _ o -, sin empezar por «managed-».",
"newAccountHint": "La cuenta se crea sin contraseña ni correo. Antes de entregarla, pon su primera contraseña con Restablecer contraseña en Usuarios.",
"accountLabel": "Cuenta",
"accountPlaceholder": "Elige una cuenta",
"checking": "Comprobando la cuenta…",
"eligible": "Esta cuenta aún no tiene registros y puede recibir la copia.",
"confirmLabel": "Escribe {username} para confirmar",
"submit": "Subir a esta cuenta",
"submitting": "Subiendo…",
"success": "Copia guardada para {username} ({count} registros). Restáurala desde su fila.",
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
"failureAdminAccount": "Esta es una cuenta de administrador. Los registros de otra persona no pueden ir en ella, así que elige una cuenta normal o crea una nueva.",
"failureNotFound": "Esta cuenta ya no existe.",
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
"failureOwnerExists": "La cuenta a la que pertenece este archivo está en este servidor, y el archivo se restaura en esa cuenta. Súbelo de nuevo sin elegir una cuenta."
}
},
"danger-zone": {
"title": "Zona de peligro",
@@ -9243,6 +9389,13 @@
"titleFailing": "Las copias de seguridad están en riesgo",
"titleReminder": "Las copias de seguridad siguen en riesgo",
"titleRecovered": "Las copias de seguridad vuelven a la normalidad"
},
"systemJobAlert": {
"titleFailing": "Tarea en segundo plano con fallos: {queue}",
"titleReminder": "La tarea en segundo plano sigue fallando: {queue}",
"titleRecovered": "La tarea en segundo plano vuelve a funcionar: {queue}",
"bodyFailing": "La tarea en segundo plano {queue} ha fallado {failures} veces seguidas y aparece en la página de estado de administración. Último error: {error}",
"bodyRecovered": "La tarea en segundo plano {queue} se ha vuelto a ejecutar correctamente."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos los días de 15:30 a 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Países Bajos, 24 horas, gratuito, anónimo, también por chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anónimo)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Inicio",
"end": "Fin",
"seriesCaption": "Medias por periodo",
"period": "Periodo"
"period": "Periodo",
"labsCaption": "Valores de laboratorio por día",
"marker": "Marcador",
"date": "Fecha",
"value": "Valor"
},
"selection": {
"empty": "No hay entradas en este periodo",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 valor de laboratorio",
"labValuesFew": "{count} valores de laboratorio",
"labValuesOther": "{count} valores de laboratorio"
},
"labs": {
"menuTitle": "Valores de laboratorio",
"otherPanel": "Otros",
"full": "Hasta {max} valores de laboratorio a la vez. Quita uno para añadir otro.",
"above": "por encima del intervalo",
"below": "por debajo del intervalo",
"reference": "referencia {range}",
"report": "Informe: {title}",
"reportUntitled": "Abrir el informe",
"excludedOne": "{name}: {count} valor en otra unidad no se muestra",
"excludedFew": "{name}: {count} valores en otra unidad no se muestran",
"excludedOther": "{name}: {count} valores en otra unidad no se muestran"
},
"legendLabValue": "Valor de laboratorio, tal como se midió",
"legendLabOutside": "Fuera del 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": {
@@ -14016,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."
}
}
+843 -558
View File
File diff suppressed because it is too large. Load diff
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Salva il tuo kit di recupero",
"body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.",
"action": "Apri il kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Ti diamo il benvenuto.",
"welcomeNewWithName": "Ti diamo il benvenuto, {name}."
},
"measurements": {
"title": "Misurazioni",
@@ -816,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",
@@ -966,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",
@@ -1892,6 +1901,7 @@
"intakeHistoryColScheduled": "Programmata",
"intakeHistoryColStatus": "Stato",
"intakeHistoryColSource": "Sorgente",
"intakeHistoryInjectionSite": "Sito di iniezione: {site}",
"intakeHistoryStatusTaken": "Assunta",
"intakeHistoryStatusSkipped": "Saltata",
"intakeHistorySortByTaken": "Ordina per ora di assunzione",
@@ -4755,7 +4765,8 @@
"unitScore": "punti",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carico"
},
"sleepQuality": {
"title": "Qualità del sonno",
@@ -4777,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."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Password",
"totp": "Codice dell’app di autenticazione",
"passkey": "Passkey",
"webauthn": "Chiave di sicurezza"
"webauthn": "Chiave di sicurezza",
"oidc": "Provider di identità"
},
"currentPassword": "Password attuale",
"authenticatorCode": "Codice dell’app di autenticazione",
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
"verifyAndAdd": "Verifica e aggiungi",
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
"verifyAndContinue": "Verifica e continua"
"verifyAndContinue": "Verifica e continua",
"oidcHint": "Accedi di nuovo presso il tuo provider di identità e torni subito a questa pagina. Poi ripeti quello che stavi per fare.",
"continueToProvider": "Continua all'accesso"
},
"passkeyName": "Nome",
"noPasskeys": "Nessuna passkey registrata.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
"failed": "Non ha funzionato. Controlla quanto inserito e riprova.",
"oidcDone": "Confermato. Per i prossimi cinque minuti puoi procedere senza che ti venga chiesto di nuovo.",
"oidcFailed": "Il tuo provider di identità non ha potuto confermare che sei tu. Riprova."
},
"passwordTitle": "Password",
"tokenNameLabel": "Nome del token",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
"undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi. I suoi link di condivisione e token restano revocati e vanno configurati di nuovo.",
"undoDeletionDone": "Eliminazione di {name} annullata",
"undoDeletionFailed": "Impossibile annullare l’eliminazione"
"undoDeletionFailed": "Impossibile annullare l’eliminazione",
"undoDeletionBodyAdmin": "Un amministratore ha eliminato l’account {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
"deleteUser": "Elimina account",
"deleteUserTitle": "Eliminare l’account {name}?",
"deleteUserBodyGrace": "{name} viene disconnesso ovunque subito e non può più accedere. L’account e tutto il suo contenuto vengono cancellati il {date}. Fino ad allora puoi annullare l’eliminazione da qui.",
"deleteUserBodyNow": "{name} e tutto il contenuto dell’account vengono cancellati subito. Questo server non ha un periodo di tolleranza, quindi l’eliminazione non si può annullare.",
"deleteUserConfirm": "Elimina account",
"deleteUserScheduled": "L’account {name} è bloccato e verrà cancellato il {date}",
"deleteUserDone": "L’account {name} è stato eliminato",
"deleteUserFailed": "Impossibile eliminare l’account"
},
"invites": {
"title": "Inviti",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Download non riuscito",
"downloadStarted": "Download avviato",
"uploadTitle": "Carica backup",
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.",
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. Una copia il cui account non è più su questo server può andare in un altro account. Il ripristino è un passaggio a parte.",
"uploadButton": "Carica file",
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
"uploadSuccess": "Backup caricato ({count} record)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.",
"restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
"restoreFailureRowsElsewhere": "I record di questo file sono già in un altro account di questo server: lo stesso file è già stato ripristinato lì. Un file può essere ripristinato in un solo account. Non è stato scritto nulla.",
"previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.",
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)",
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "Il dump più recente nel bucket è del {when}.",
"offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket."
},
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano."
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano.",
"target": {
"title": "Ripristina in un altro account",
"description": "{file} appartiene a un account che non è più su questo server. Scegli l’account in cui vanno i suoi record. Un ripristino sostituisce i record di un account, quindi solo un account senza record può accoglierli. Il file viene caricato un’altra volta.",
"choiceLabel": "Dove vanno i record",
"newAccount": "Nuovo account",
"existingAccount": "Account esistente",
"usernameLabel": "Nome utente del nuovo account",
"usernameInvalid": "Da 3 a 30 lettere, cifre, _ o -, senza iniziare con “managed-”.",
"newAccountHint": "L’account viene creato senza password né email. Prima di consegnarlo, imposta la sua prima password con Reimposta password in Utenti.",
"accountLabel": "Account",
"accountPlaceholder": "Scegli un account",
"checking": "Controllo dell’account…",
"eligible": "Questo account non ha ancora record e può accogliere la copia.",
"confirmLabel": "Digita {username} per confermare",
"submit": "Carica in questo account",
"submitting": "Caricamento…",
"success": "Backup salvato per {username} ({count} record). Ripristinalo dalla sua riga.",
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
"failureAdminAccount": "Questo è un account amministratore. I record di un’altra persona non possono andarci, quindi scegli un account normale o creane uno nuovo.",
"failureNotFound": "Questo account non esiste più.",
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
"failureUsernameTaken": "Questo nome utente è già in uso.",
"failureOwnerExists": "L’account a cui appartiene questo file è su questo server, e il file si ripristina in quell’account. Caricalo di nuovo senza scegliere un account."
}
},
"danger-zone": {
"title": "Zona pericolosa",
@@ -9243,6 +9389,13 @@
"titleFailing": "I backup sono a rischio",
"titleReminder": "I backup sono ancora a rischio",
"titleRecovered": "I backup sono tornati alla normalità"
},
"systemJobAlert": {
"titleFailing": "Attività in background non riuscita: {queue}",
"titleReminder": "Attività in background ancora non riuscita: {queue}",
"titleRecovered": "Attività in background di nuovo funzionante: {queue}",
"bodyFailing": "L'attività in background {queue} non è riuscita {failures} volte di seguito ed è elencata nella pagina di stato dell'amministrazione. Ultimo errore: {error}",
"bodyRecovered": "L'attività in background {queue} è stata di nuovo eseguita correttamente."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portogallo, tutti i giorni dalle 15:30 alle 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Paesi Bassi, 24 ore su 24, gratuito, anonimo, anche in chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgio, 24 ore su 24, gratuito, anonimo)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Inizio",
"end": "Fine",
"seriesCaption": "Medie per periodo",
"period": "Periodo"
"period": "Periodo",
"labsCaption": "Valori di laboratorio per giorno",
"marker": "Marcatore",
"date": "Data",
"value": "Valore"
},
"selection": {
"empty": "Nessuna voce in questo periodo",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 valore di laboratorio",
"labValuesFew": "{count} valori di laboratorio",
"labValuesOther": "{count} valori di laboratorio"
},
"labs": {
"menuTitle": "Valori di laboratorio",
"otherPanel": "Altri",
"full": "Fino a {max} valori di laboratorio alla volta. Rimuovine uno per aggiungerne un altro.",
"above": "sopra l’intervallo",
"below": "sotto l’intervallo",
"reference": "riferimento {range}",
"report": "Referto: {title}",
"reportUntitled": "Apri il referto",
"excludedOne": "{name}: {count} valore in un’altra unità non è riportato",
"excludedFew": "{name}: {count} valori in un’altra unità non sono riportati",
"excludedOther": "{name}: {count} valori in un’altra unità non sono riportati"
},
"legendLabValue": "Valore di laboratorio, così come misurato",
"legendLabOutside": "Fuori dall’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": {
@@ -14016,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."
}
}
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "복구 키트를 보관하세요",
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
"action": "키트 열기"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "환영합니다.",
"welcomeNewWithName": "{name}님, 환영합니다."
},
"measurements": {
"title": "측정",
@@ -816,6 +819,7 @@
"typeHeartRateVariability": "심박변이도",
"typeRestingHeartRate": "안정 시 심박수",
"typeActiveEnergyBurned": "활동 에너지",
"typeBasalEnergyBurned": "휴식 에너지",
"typeFlightsClimbed": "오른 층수",
"typeWalkingRunningDistance": "걷기 + 달리기 거리",
"typeVo2Max": "VO₂ max",
@@ -966,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가 기록할 수 있는 것",
@@ -1892,6 +1901,7 @@
"intakeHistoryColScheduled": "예정",
"intakeHistoryColStatus": "상태",
"intakeHistoryColSource": "출처",
"intakeHistoryInjectionSite": "주사 부위: {site}",
"intakeHistoryStatusTaken": "복용함",
"intakeHistoryStatusSkipped": "건너뜀",
"intakeHistorySortByTaken": "복용 시각순 정렬",
@@ -4755,7 +4765,8 @@
"unitScore": "점",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "부하"
},
"sleepQuality": {
"title": "수면 품질",
@@ -4777,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": "회복 값을 불러오지 못했어요."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,7 +5286,8 @@
},
"hints": {
"apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.",
"file": "여기서 마친 뒤 설정에서 가져옵니다."
"file": "여기서 마친 뒤 설정에서 가져옵니다.",
"fitbit": "이제 Google Health를 통해 연결됩니다."
}
},
"visit": {
@@ -5290,6 +5341,7 @@
"waiting": "아직 연결되지 않았습니다.",
"openSettings": "설정 → 연동 열기",
"noDate": "알 수 없음",
"throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.",
"needsCredentials": {
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
@@ -5650,7 +5702,11 @@
"recovery": "회복",
"stress": "스트레스",
"sleepDebt": "수면 부채",
"hrvRmssd": "심박변이도 (RMSSD)"
"hrvRmssd": "심박변이도 (RMSSD)",
"bodyBattery": "바디 배터리",
"stressLevel": "스트레스 수준",
"trainingReadiness": "트레이닝 준비도",
"acuteTrainingLoad": "급성 훈련 부하"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -5668,7 +5724,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "외부 기기",
"HEALTH_CONNECT": "Health Connect"
"HEALTH_CONNECT": "Health Connect",
"GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "워치",
@@ -6237,14 +6294,17 @@
"password": "비밀번호",
"totp": "인증 앱 코드",
"passkey": "패스키",
"webauthn": "보안 키"
"webauthn": "보안 키",
"oidc": "ID 공급자"
},
"currentPassword": "현재 비밀번호",
"authenticatorCode": "인증 앱 코드",
"authenticatorPlaceholder": "6자리 인증 앱 코드",
"verifyAndAdd": "확인 후 추가",
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
"verifyAndContinue": "확인하고 계속"
"verifyAndContinue": "확인하고 계속",
"oidcHint": "ID 공급자에서 한 번 더 로그인하면 바로 이 페이지로 돌아옵니다. 그런 다음 하려던 작업을 다시 진행하세요.",
"continueToProvider": "로그인으로 계속"
},
"passkeyName": "이름",
"noPasskeys": "등록된 패스키가 없어요.",
@@ -6847,7 +6907,9 @@
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동",
"medicationDoses": "약 복용"
"medicationDoses": "약 복용",
"cycleDays": "주기 기록일",
"cycleDaysCount": "주기 기록일 ({count})"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
@@ -7459,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": "혈당 측정값을 어떤 구간에 견줘 판단할지 정해요.",
@@ -7517,7 +7574,9 @@
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요.",
"oidcDone": "확인되었습니다. 앞으로 5분 동안은 다시 묻지 않고 계속할 수 있습니다.",
"oidcFailed": "ID 공급자가 본인임을 확인하지 못했습니다. 다시 시도하세요."
},
"passwordTitle": "비밀번호",
"tokenNameLabel": "토큰 이름",
@@ -7661,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": "관리자",
@@ -8080,7 +8190,16 @@
"undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
"undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.",
"undoDeletionDone": "{name} 삭제를 취소했어요",
"undoDeletionFailed": "삭제를 취소하지 못했어요"
"undoDeletionFailed": "삭제를 취소하지 못했어요",
"undoDeletionBodyAdmin": "관리자가 {name} 계정을 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
"deleteUser": "계정 삭제",
"deleteUserTitle": "{name} 계정을 삭제할까요?",
"deleteUserBodyGrace": "{name} 님은 즉시 모든 곳에서 로그아웃되고 더 이상 로그인할 수 없어요. 계정과 그 안의 모든 내용은 {date}에 지워져요. 그때까지는 여기서 삭제를 취소할 수 있어요.",
"deleteUserBodyNow": "{name} 님과 계정 안의 모든 내용이 바로 지워져요. 이 서버에는 유예 기간이 없어서 삭제를 취소할 수 없어요.",
"deleteUserConfirm": "계정 삭제",
"deleteUserScheduled": "{name} 계정이 잠겼고 {date}에 지워져요",
"deleteUserDone": "{name} 계정을 삭제했어요",
"deleteUserFailed": "계정을 삭제하지 못했어요"
},
"invites": {
"title": "초대",
@@ -8175,7 +8294,7 @@
"downloadFailed": "다운로드하지 못했어요",
"downloadStarted": "다운로드를 시작했어요",
"uploadTitle": "백업 업로드",
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.",
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 계정이 이 서버에 더 이상 없는 사본은 다른 계정으로 보낼 수 있어요. 복원은 별도 단계예요.",
"uploadButton": "파일 업로드",
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
@@ -8303,6 +8422,7 @@
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.",
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
"restoreFailureRowsElsewhere": "이 파일의 기록은 이미 이 서버의 다른 계정에 있습니다. 같은 파일이 그 계정에 먼저 복원되었습니다. 파일은 한 계정에만 복원할 수 있습니다. 아무것도 기록되지 않았습니다.",
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
@@ -8334,7 +8454,33 @@
"offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.",
"offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다."
},
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요."
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요.",
"target": {
"title": "다른 계정으로 복원",
"description": "{file}은(는) 이 서버에 더 이상 없는 계정의 파일이에요. 기록을 넣을 계정을 골라 주세요. 복원은 계정의 기록을 바꿔 쓰기 때문에 기록이 없는 계정만 받을 수 있어요. 파일은 한 번 더 업로드돼요.",
"choiceLabel": "기록을 넣을 곳",
"newAccount": "새 계정",
"existingAccount": "기존 계정",
"usernameLabel": "새 계정의 사용자 이름",
"usernameInvalid": "영문자, 숫자, _ 또는 - 3~30자이고, \"managed-\"로 시작하면 안 돼요.",
"newAccountHint": "계정은 비밀번호와 이메일 없이 만들어져요. 넘겨주기 전에 사용자에서 비밀번호 재설정으로 첫 비밀번호를 정해 주세요.",
"accountLabel": "계정",
"accountPlaceholder": "계정을 골라 주세요",
"checking": "계정을 확인하는 중…",
"eligible": "이 계정에는 아직 기록이 없어서 사본을 받을 수 있어요.",
"confirmLabel": "확인하려면 {username}을(를) 입력해 주세요",
"submit": "이 계정으로 업로드",
"submitting": "업로드 중…",
"success": "{username}의 백업을 저장했어요 (기록 {count}개). 해당 행에서 복원해 주세요.",
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
"failureAdminAccount": "관리자 계정이에요. 다른 사람의 기록은 이 계정에 넣을 수 없으니 일반 계정을 고르거나 새 계정을 만들어 주세요.",
"failureNotFound": "이 계정은 더 이상 없어요.",
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
"failureOwnerExists": "이 파일의 계정이 이 서버에 있어서 파일은 그 계정으로 복원돼요. 계정을 고르지 말고 다시 업로드해 주세요."
}
},
"danger-zone": {
"title": "위험 구역",
@@ -9243,6 +9389,13 @@
"titleFailing": "백업이 위험해요",
"titleReminder": "백업이 여전히 위험해요",
"titleRecovered": "백업이 다시 정상이에요"
},
"systemJobAlert": {
"titleFailing": "백그라운드 작업이 실패하고 있어요: {queue}",
"titleReminder": "백그라운드 작업이 계속 실패하고 있어요: {queue}",
"titleRecovered": "백그라운드 작업이 다시 작동해요: {queue}",
"bodyFailing": "백그라운드 작업 {queue}이(가) {failures}번 연속 실패했어요. 관리자 상태 페이지에서 확인할 수 있어요. 마지막 오류: {error}",
"bodyRecovered": "백그라운드 작업 {queue}이(가) 다시 정상적으로 실행됐어요."
}
},
"i18n": {
@@ -9873,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": "생리가 곧 시작될 것 같아요",
@@ -10169,6 +10385,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
@@ -10342,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}",
@@ -10396,6 +10632,7 @@
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
@@ -10423,6 +10660,10 @@
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
},
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
},
"unit": {
"none": "단위 없음",
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
}
},
"modules": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (포르투갈, 매일 15:30~00:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (네덜란드, 24시간, 무료, 익명, 채팅 가능)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (벨기에, 24시간, 무료, 익명)"
}
},
"instrumentDescription": {
@@ -11846,7 +12093,8 @@
"unsupportedType": "지원하지 않는 파일 형식이에요.",
"purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.",
"duplicateExists": "같은 문서가 이미 저장돼 있어요.",
"rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.",
"uploadBusy": "서버가 다른 업로드를 처리하느라 바빴어요. 지금 다시 시도해 주세요.",
"rateLimited": "이번 시간의 업로드 한도에 도달했어요. 나중에 다시 시도해 주세요.",
"generic": "업로드하지 못했어요. 다시 시도해 주세요."
},
"toast": {
@@ -11897,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는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
}
@@ -11980,7 +12228,7 @@
"errorRateLimitedWaitFew": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
"errorRateLimitedWaitOther": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
"errorBudget": "오늘 쓸 수 있는 AI 예산을 다 썼어요.",
"errorPdfNeedsVision": "PDF를 읽으려면 Claude 비전 제공자가 필요해요.",
"errorPdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 PDF는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있고, 페이지를 사진으로 올려도 돼요.",
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
@@ -12997,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": {
@@ -13174,7 +13426,9 @@
"specialty": "{date} 마지막 {specialty} 방문 이후",
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
"default": "최근 {days}일",
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
"capped": "최근 2년",
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
}
},
"checkups": {
@@ -13625,7 +13879,11 @@
"start": "시작",
"end": "끝",
"seriesCaption": "기간별 평균",
"period": "기간"
"period": "기간",
"labsCaption": "날짜별 검사 수치",
"marker": "항목",
"date": "날짜",
"value": "값"
},
"selection": {
"empty": "이 기간에는 항목이 없습니다",
@@ -13670,6 +13928,32 @@
"labValuesOne": "검사 수치 1개",
"labValuesFew": "검사 수치 {count}개",
"labValuesOther": "검사 수치 {count}개"
},
"labs": {
"menuTitle": "검사 수치",
"otherPanel": "기타",
"full": "검사 수치는 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 수치를 추가하려면 하나를 제거하세요.",
"above": "범위 초과",
"below": "범위 미만",
"reference": "참고 범위 {range}",
"report": "보고서: {title}",
"reportUntitled": "보고서 열기",
"excludedOne": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
"excludedFew": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
"excludedOther": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다"
},
"legendLabValue": "측정된 그대로의 검사 수치",
"legendLabOutside": "참고 범위 밖",
"legendLabRange": "참고 범위",
"legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값",
"symptoms": {
"menuTitle": "증상",
"full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.",
"hidden": "숨김",
"unnamed": "증상",
"daysOne": "1일",
"daysFew": "{count}일 평균",
"daysOther": "{count}일 평균"
}
},
"lifeEvents": {
@@ -14016,6 +14300,7 @@
"asNeeded": "필요시",
"startsOn": "시작",
"endsOn": "종료"
}
},
"unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요."
}
}
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Bewaar je herstelkit",
"body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.",
"action": "Kit openen"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Welkom.",
"welcomeNewWithName": "Welkom, {name}."
},
"measurements": {
"title": "Metingen",
@@ -816,6 +819,7 @@
"typeHeartRateVariability": "Hartslagvariabiliteit",
"typeRestingHeartRate": "Rusthartslag",
"typeActiveEnergyBurned": "Actieve energie",
"typeBasalEnergyBurned": "Rustenergie",
"typeFlightsClimbed": "Beklommen verdiepingen",
"typeWalkingRunningDistance": "Afstand wandelen + hardlopen",
"typeVo2Max": "VO₂max",
@@ -966,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",
@@ -1892,6 +1901,7 @@
"intakeHistoryColScheduled": "Gepland",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Bron",
"intakeHistoryInjectionSite": "Injectieplaats: {site}",
"intakeHistoryStatusTaken": "Ingenomen",
"intakeHistoryStatusSkipped": "Overgeslagen",
"intakeHistorySortByTaken": "Sorteren op innametijd",
@@ -4755,7 +4765,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "belasting"
},
"sleepQuality": {
"title": "Slaapkwaliteit",
@@ -4774,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.",
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Wachtwoord",
"totp": "Authenticatorcode",
"passkey": "Passkey",
"webauthn": "Beveiligingssleutel"
"webauthn": "Beveiligingssleutel",
"oidc": "Identiteitsprovider"
},
"currentPassword": "Huidig wachtwoord",
"authenticatorCode": "Authenticatorcode",
"authenticatorPlaceholder": "Authenticatorcode van 6 cijfers",
"verifyAndAdd": "Verifiëren en toevoegen",
"factorDescription": "Verifieer een bestaande inlogmethode voordat je een tweede factor toevoegt.",
"verifyAndContinue": "Verifiëren en doorgaan"
"verifyAndContinue": "Verifiëren en doorgaan",
"oidcHint": "Je logt nog een keer in bij je identiteitsprovider en komt meteen terug op deze pagina. Doe daarna opnieuw wat je wilde doen.",
"continueToProvider": "Verder naar inloggen"
},
"passkeyName": "Naam",
"noPasskeys": "Geen passkeys geregistreerd.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Hiermee deel of exporteer je je gezondheidsgegevens. Bevestig dat jij het bent om door te gaan.",
"descriptionChange": "Hiermee wijzig je iets wat alleen jij zou moeten kunnen wijzigen. Bevestig dat jij het bent om door te gaan.",
"signInAgain": "Log opnieuw in en probeer het daarna nog een keer. Je account heeft geen wachtwoord of passkey waarmee je hier kunt bevestigen.",
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw."
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw.",
"oidcDone": "Bevestigd. De komende vijf minuten kun je verder zonder dat het opnieuw wordt gevraagd.",
"oidcFailed": "Je identiteitsprovider kon niet bevestigen dat jij het bent. Probeer het opnieuw."
},
"passwordTitle": "Wachtwoord",
"tokenNameLabel": "Tokennaam",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
"undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken. De deellinks en tokens blijven ingetrokken en moeten opnieuw worden ingesteld.",
"undoDeletionDone": "Verwijdering van {name} ongedaan gemaakt",
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt"
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt",
"undoDeletionBodyAdmin": "Een beheerder heeft het account {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
"deleteUser": "Account verwijderen",
"deleteUserTitle": "Het account {name} verwijderen?",
"deleteUserBodyGrace": "{name} wordt meteen overal uitgelogd en kan niet meer inloggen. Het account en alles erin wordt op {date} gewist. Tot dan kun je de verwijdering hier ongedaan maken.",
"deleteUserBodyNow": "{name} en alles in het account wordt meteen gewist. Deze server heeft geen bedenktijd, dus de verwijdering kan niet ongedaan worden gemaakt.",
"deleteUserConfirm": "Account verwijderen",
"deleteUserScheduled": "Het account {name} is vergrendeld en wordt op {date} gewist",
"deleteUserDone": "Het account {name} is verwijderd",
"deleteUserFailed": "Het account kon niet worden verwijderd"
},
"invites": {
"title": "Uitnodigingen",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Downloaden mislukt",
"downloadStarted": "Download gestart",
"uploadTitle": "Back-up uploaden",
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.",
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Een kopie waarvan het account niet meer op deze server staat, kan naar een ander account. Terugzetten is een aparte stap.",
"uploadButton": "Bestand uploaden",
"uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.",
"uploadSuccess": "Back-up geüpload ({count} records)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "Het terugzetten stopte door een onverwachte fout en is teruggedraaid. In het serverlog staat de fout.",
"restoreFailureKeyMissing": "De back-up heeft versleutelingssleutel {keys} nodig, waarmee deze server hem niet kan openen. Voeg de sleutel weer toe aan ENCRYPTION_KEYS, herstart en start het terugzetten opnieuw.",
"restoreFailureForeignReference": "Het bestand verwijst naar records die bij een ander account horen. Er is niets geschreven.",
"restoreFailureRowsElsewhere": "De records in dit bestand staan al in een ander account op deze server: hetzelfde bestand is daar al teruggezet. Een bestand kan maar in één account worden teruggezet. Er is niets geschreven.",
"previewKeyMissing": "Deze back-up kan niet op deze server worden teruggezet: er is versleutelingssleutel {keys} voor nodig. Voeg de sleutel eerst weer toe aan ENCRYPTION_KEYS en herstart.",
"restoreSkippedSymptomEpisodeReference": "Symptoomregistratie gekoppeld aan een ziekte die niet in het bestand staat (behouden zonder de koppeling)",
"restoreSkippedCycleCiphertext": "Cyclusnotitie of -label versleuteld met een sleutel die deze server niet heeft (dag of symptoom behouden zonder)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "De nieuwste dump in de bucket is van {when}.",
"offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen."
},
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug."
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug.",
"target": {
"title": "In een ander account terugzetten",
"description": "{file} hoort bij een account dat niet meer op deze server staat. Kies het account waar de records naartoe gaan. Terugzetten vervangt de records van een account, dus alleen een account zonder records kan ze opnemen. Het bestand wordt nog een keer geüpload.",
"choiceLabel": "Waar de records naartoe gaan",
"newAccount": "Nieuw account",
"existingAccount": "Bestaand account",
"usernameLabel": "Gebruikersnaam voor het nieuwe account",
"usernameInvalid": "3 tot 30 letters, cijfers, _ of -, niet beginnend met ‘managed-’.",
"newAccountHint": "Het account wordt zonder wachtwoord en zonder e-mail aangemaakt. Stel het eerste wachtwoord in met Wachtwoord resetten onder Gebruikers voordat je het overdraagt.",
"accountLabel": "Account",
"accountPlaceholder": "Kies een account",
"checking": "Account wordt gecontroleerd…",
"eligible": "Dit account heeft nog geen records en kan de kopie opnemen.",
"confirmLabel": "Typ {username} om te bevestigen",
"submit": "Naar dit account uploaden",
"submitting": "Uploaden…",
"success": "Back-up opgeslagen voor {username} ({count} records). Zet hem terug vanuit zijn rij.",
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
"failureAdminAccount": "Dit is een beheerdersaccount. De records van iemand anders kunnen er niet in, dus kies een gewoon account of maak een nieuw account aan.",
"failureNotFound": "Dit account bestaat niet meer.",
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
"failureOwnerExists": "Het account waar dit bestand bij hoort, staat op deze server, en het bestand wordt in dat account teruggezet. Upload het opnieuw zonder een account te kiezen."
}
},
"danger-zone": {
"title": "Gevarenzone",
@@ -9243,6 +9389,13 @@
"titleFailing": "Back-ups lopen gevaar",
"titleReminder": "Back-ups lopen nog steeds gevaar",
"titleRecovered": "Back-ups zijn weer in orde"
},
"systemJobAlert": {
"titleFailing": "Achtergrondtaak mislukt: {queue}",
"titleReminder": "Achtergrondtaak mislukt nog steeds: {queue}",
"titleRecovered": "Achtergrondtaak werkt weer: {queue}",
"bodyFailing": "De achtergrondtaak {queue} is {failures} keer achter elkaar mislukt en staat op de beheerstatuspagina. Laatste fout: {error}",
"bodyRecovered": "De achtergrondtaak {queue} is weer met succes uitgevoerd."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, dagelijks van 15.30 tot 0.30 uur)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Nederland, dag en nacht, gratis, anoniem, ook via chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (België, dag en nacht, gratis, anoniem)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Begin",
"end": "Einde",
"seriesCaption": "Gemiddelde waarden per periode",
"period": "Periode"
"period": "Periode",
"labsCaption": "Labwaarden per dag",
"marker": "Marker",
"date": "Datum",
"value": "Waarde"
},
"selection": {
"empty": "Geen registraties in deze periode",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 labwaarde",
"labValuesFew": "{count} labwaarden",
"labValuesOther": "{count} labwaarden"
},
"labs": {
"menuTitle": "Labwaarden",
"otherPanel": "Overig",
"full": "Maximaal {max} labwaarden tegelijk. Verwijder er een om een andere toe te voegen.",
"above": "boven het bereik",
"below": "onder het bereik",
"reference": "referentie {range}",
"report": "Verslag: {title}",
"reportUntitled": "Verslag openen",
"excludedOne": "{name}: {count} waarde in een andere eenheid wordt niet getoond",
"excludedFew": "{name}: {count} waarden in een andere eenheid worden niet getoond",
"excludedOther": "{name}: {count} waarden in een andere eenheid worden niet getoond"
},
"legendLabValue": "Labwaarde, zoals gemeten",
"legendLabOutside": "Buiten het 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": {
@@ -14016,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."
}
}
+315 -30
View File
@@ -789,7 +789,10 @@
"title": "Zapisz zestaw odzyskiwania",
"body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.",
"action": "Otwórz zestaw"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Witaj.",
"welcomeNewWithName": "Witaj, {name}."
},
"measurements": {
"title": "Pomiary",
@@ -816,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",
@@ -966,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ć",
@@ -1892,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",
@@ -4755,7 +4765,8 @@
"unitScore": "pkt",
"unitMinutes": "min",
"unitBpm": "ud./min",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "obciążenie"
},
"sleepQuality": {
"title": "Jakość snu",
@@ -4777,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."
},
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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ć."
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Hasło",
"totp": "Kod z aplikacji uwierzytelniającej",
"passkey": "Klucz dostępu",
"webauthn": "Klucz bezpieczeństwa"
"webauthn": "Klucz bezpieczeństwa",
"oidc": "Dostawca tożsamości"
},
"currentPassword": "Aktualne hasło",
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
"verifyAndAdd": "Zweryfikuj i dodaj",
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
"verifyAndContinue": "Potwierdź i kontynuuj"
"verifyAndContinue": "Potwierdź i kontynuuj",
"oidcHint": "Zalogujesz się jeszcze raz u swojego dostawcy tożsamości i wrócisz od razu na tę stronę. Potem powtórz to, co chcesz zrobić.",
"continueToProvider": "Przejdź do logowania"
},
"passkeyName": "Nazwa",
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
@@ -6847,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ń."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie.",
"oidcDone": "Potwierdzono. Przez następne pięć minut możesz kontynuować bez ponownego pytania.",
"oidcFailed": "Twój dostawca tożsamości nie mógł potwierdzić, że to Ty. Spróbuj ponownie."
},
"passwordTitle": "Hasło",
"tokenNameLabel": "Nazwa tokena",
@@ -7661,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
"undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp. Jego linki udostępniania i tokeny pozostają unieważnione i trzeba je skonfigurować od nowa.",
"undoDeletionDone": "Cofnięto usunięcie {name}",
"undoDeletionFailed": "Nie udało się cofnąć usunięcia"
"undoDeletionFailed": "Nie udało się cofnąć usunięcia",
"undoDeletionBodyAdmin": "Administrator usunął konto {name}; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
"deleteUser": "Usuń konto",
"deleteUserTitle": "Usunąć konto {name}?",
"deleteUserBodyGrace": "{name} zostanie od razu wszędzie wylogowany i nie będzie mógł się już zalogować. Konto i cała jego zawartość zostaną usunięte {date}. Do tego czasu możesz tutaj cofnąć usunięcie.",
"deleteUserBodyNow": "{name} i cała zawartość konta zostaną usunięte od razu. Ten serwer nie ma okresu karencji, więc usunięcia nie da się cofnąć.",
"deleteUserConfirm": "Usuń konto",
"deleteUserScheduled": "Konto {name} jest zablokowane i zostanie usunięte {date}",
"deleteUserDone": "Konto {name} zostało usunięte",
"deleteUserFailed": "Nie udało się usunąć konta"
},
"invites": {
"title": "Zaproszenia",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Pobieranie nie powiodło się",
"downloadStarted": "Rozpoczęto pobieranie",
"uploadTitle": "Prześlij kopię zapasową",
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.",
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Kopia, której konta nie ma już na tym serwerze, może trafić do innego konta. Przywracanie to osobny krok.",
"uploadButton": "Prześlij plik",
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.",
"restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
"restoreFailureRowsElsewhere": "Rekordy z tego pliku są już na innym koncie na tym serwerze: ten sam plik został tam wcześniej przywrócony. Plik można przywrócić tylko na jedno konto. Nic nie zostało zapisane.",
"previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.",
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)",
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.",
"offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych."
},
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają."
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają.",
"target": {
"title": "Przywróć do innego konta",
"description": "{file} należy do konta, którego nie ma już na tym serwerze. Wybierz konto, do którego trafią jego rekordy. Przywracanie zastępuje rekordy konta, więc przyjąć je może tylko konto bez rekordów. Plik zostanie przesłany jeszcze raz.",
"choiceLabel": "Dokąd trafią rekordy",
"newAccount": "Nowe konto",
"existingAccount": "Istniejące konto",
"usernameLabel": "Nazwa użytkownika nowego konta",
"usernameInvalid": "Od 3 do 30 liter, cyfr, _ lub -, bez „managed-” na początku.",
"newAccountHint": "Konto zostanie utworzone bez hasła i bez adresu e-mail. Zanim je przekażesz, ustaw jego pierwsze hasło przyciskiem Zresetuj hasło w sekcji Użytkownicy.",
"accountLabel": "Konto",
"accountPlaceholder": "Wybierz konto",
"checking": "Sprawdzanie konta…",
"eligible": "To konto nie ma jeszcze rekordów i może przyjąć kopię.",
"confirmLabel": "Wpisz {username}, aby potwierdzić",
"submit": "Prześlij do tego konta",
"submitting": "Przesyłanie…",
"success": "Kopia zapisana dla {username} ({count} rekordów). Przywróć ją z jej wiersza.",
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
"failureAdminAccount": "To jest konto administratora. Nie można w nim umieścić rekordów innej osoby, więc wybierz zwykłe konto albo utwórz nowe.",
"failureNotFound": "To konto już nie istnieje.",
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
"failureOwnerExists": "Konto, do którego należy ten plik, jest na tym serwerze, a plik przywraca się do tego konta. Prześlij go ponownie bez wybierania konta."
}
},
"danger-zone": {
"title": "Strefa zagrożenia",
@@ -9243,6 +9389,13 @@
"titleFailing": "Kopie zapasowe są zagrożone",
"titleReminder": "Kopie zapasowe są nadal zagrożone",
"titleRecovered": "Kopie zapasowe znów działają normalnie"
},
"systemJobAlert": {
"titleFailing": "Zadanie w tle kończy się błędem: {queue}",
"titleReminder": "Zadanie w tle nadal kończy się błędem: {queue}",
"titleRecovered": "Zadanie w tle znów działa: {queue}",
"bodyFailing": "Zadanie w tle {queue} zakończyło się błędem {failures} razy z rzędu i jest widoczne na stronie stanu w panelu administratora. Ostatni błąd: {error}",
"bodyRecovered": "Zadanie w tle {queue} znów wykonało się poprawnie."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugalia, codziennie od 15:30 do 0:30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Holandia, całą dobę, bezpłatnie, anonimowo, także przez czat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgia, całą dobę, bezpłatnie, anonimowo)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13625,7 +13879,11 @@
"start": "Początek",
"end": "Koniec",
"seriesCaption": "Średnie według okresu",
"period": "Okres"
"period": "Okres",
"labsCaption": "Wyniki badań według dni",
"marker": "Parametr",
"date": "Data",
"value": "Wartość"
},
"selection": {
"empty": "Brak wpisów w tym okresie",
@@ -13670,6 +13928,32 @@
"labValuesOne": "1 wynik badania",
"labValuesFew": "{count} wyniki badań",
"labValuesOther": "{count} wyników badań"
},
"labs": {
"menuTitle": "Wyniki badań",
"otherPanel": "Inne",
"full": "Naraz można pokazać do {max} wyników badań. Usuń jeden, aby dodać inny.",
"above": "powyżej zakresu",
"below": "poniżej zakresu",
"reference": "zakres {range}",
"report": "Dokument: {title}",
"reportUntitled": "Otwórz dokument",
"excludedOne": "{name}: {count} wartość w innej jednostce nie jest pokazana",
"excludedFew": "{name}: {count} wartości w innej jednostce nie są pokazane",
"excludedOther": "{name}: {count} wartości w innej jednostce nie jest pokazanych"
},
"legendLabValue": "Wynik badania, tak jak zmierzono",
"legendLabOutside": "Poza zakresem referencyjnym",
"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": {
@@ -14016,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."
}
}
+344 -59
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": {
@@ -789,7 +789,10 @@
"title": "Guarde seu kit de recuperação",
"body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.",
"action": "Abrir o kit"
}
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Boas-vindas.",
"welcomeNewWithName": "Boas-vindas, {name}."
},
"measurements": {
"title": "Medições",
@@ -816,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",
@@ -966,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",
@@ -1892,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",
@@ -2277,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",
@@ -2349,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",
@@ -3228,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": {
@@ -3328,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.",
@@ -3932,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).",
@@ -3986,7 +3996,7 @@
"bedtime": "Hora de dormir",
"unspecified": "Sem marcação",
"readingsShort": "{count} leituras",
"readingsShortOne": "1 leitura"
"readingsShortOne": "{count} leitura"
}
}
}
@@ -4755,7 +4765,8 @@
"unitScore": "pontuação",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
"unitKj": "kJ",
"unitLoad": "carga"
},
"sleepQuality": {
"title": "Qualidade do sono",
@@ -4777,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."
},
@@ -4920,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"
@@ -4934,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",
@@ -4968,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": {
@@ -5229,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5236,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": {
@@ -5290,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."
@@ -5390,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": {
@@ -5650,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",
@@ -5668,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",
@@ -6237,14 +6294,17 @@
"password": "Senha",
"totp": "Código do autenticador",
"passkey": "Chave de acesso",
"webauthn": "Chave de segurança"
"webauthn": "Chave de segurança",
"oidc": "Provedor de identidade"
},
"currentPassword": "Senha atual",
"authenticatorCode": "Código do autenticador",
"authenticatorPlaceholder": "Código do autenticador de 6 dígitos",
"verifyAndAdd": "Verificar e adicionar",
"factorDescription": "Verifique um método de entrada existente antes de adicionar um segundo fator.",
"verifyAndContinue": "Verificar e continuar"
"verifyAndContinue": "Verificar e continuar",
"oidcHint": "Você entra mais uma vez no seu provedor de identidade e volta direto para esta página. Depois, repita o que ia fazer.",
"continueToProvider": "Continuar para o login"
},
"passkeyName": "Nome",
"noPasskeys": "Nenhuma chave de acesso registrada.",
@@ -6847,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."
@@ -7459,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.",
@@ -7517,7 +7574,9 @@
"description": "Isto compartilha ou exporta os seus dados de saúde. Confirme que é você para continuar.",
"descriptionChange": "Isto altera algo que só você deveria poder alterar. Confirme que é você para continuar.",
"signInAgain": "Entre novamente e tente mais uma vez. Sua conta não tem senha nem chave de acesso para confirmar aqui.",
"failed": "Não deu certo. Confira o que você digitou e tente de novo."
"failed": "Não deu certo. Confira o que você digitou e tente de novo.",
"oidcDone": "Confirmado. Nos próximos cinco minutos você pode continuar sem que isso seja pedido de novo.",
"oidcFailed": "Seu provedor de identidade não conseguiu confirmar que é você. Tente novamente."
},
"passwordTitle": "Senha",
"tokenNameLabel": "Nome do token",
@@ -7661,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",
@@ -7905,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",
@@ -8105,7 +8215,16 @@
"undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
"undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso. Os links de compartilhamento e tokens continuam revogados e precisam ser configurados de novo.",
"undoDeletionDone": "Exclusão de {name} desfeita",
"undoDeletionFailed": "Não foi possível desfazer a exclusão"
"undoDeletionFailed": "Não foi possível desfazer a exclusão",
"undoDeletionBodyAdmin": "Um administrador excluiu a conta {name}, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
"deleteUser": "Excluir conta",
"deleteUserTitle": "Excluir a conta {name}?",
"deleteUserBodyGrace": "{name} sai da conta em todos os lugares na hora e não consegue mais entrar. A conta e tudo o que há nela são apagados em {date}. Até lá, você pode desfazer a exclusão aqui.",
"deleteUserBodyNow": "{name} e tudo o que há na conta são apagados na hora. Este servidor não tem prazo de carência, então a exclusão não pode ser desfeita.",
"deleteUserConfirm": "Excluir conta",
"deleteUserScheduled": "A conta {name} está bloqueada e será apagada em {date}",
"deleteUserDone": "A conta {name} foi excluída",
"deleteUserFailed": "Não foi possível excluir a conta"
},
"invites": {
"title": "Convites",
@@ -8200,7 +8319,7 @@
"downloadFailed": "Falha no download",
"downloadStarted": "Download iniciado",
"uploadTitle": "Enviar backup",
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.",
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. Uma cópia cuja conta não está mais neste servidor pode ir para outra conta. Restaurar é uma etapa separada.",
"uploadButton": "Enviar arquivo",
"uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.",
"uploadSuccess": "Backup enviado ({count} registros)",
@@ -8328,6 +8447,7 @@
"restoreFailureUnexpected": "A restauração parou em um erro inesperado e foi revertida. O log do servidor nomeia o erro.",
"restoreFailureKeyMissing": "O backup precisa da chave de criptografia {keys}, que este servidor não consegue usar para abri-lo. Adicione a chave de volta a ENCRYPTION_KEYS, reinicie e inicie a restauração novamente.",
"restoreFailureForeignReference": "O arquivo aponta para registros que pertencem a outra conta. Nada foi gravado.",
"restoreFailureRowsElsewhere": "Os registros deste arquivo já estão em outra conta deste servidor: o mesmo arquivo já foi restaurado nela. Um arquivo só pode ser restaurado em uma conta. Nada foi gravado.",
"previewKeyMissing": "Este backup não pode ser restaurado neste servidor: ele precisa da chave de criptografia {keys}. Adicione a chave de volta a ENCRYPTION_KEYS e reinicie primeiro.",
"restoreSkippedSymptomEpisodeReference": "Entrada de sintoma vinculada a uma doença que o arquivo não contém (mantida sem o link)",
"restoreSkippedCycleCiphertext": "Nota ou rótulo do ciclo selado com uma chave que este servidor não possui (dia ou sintoma mantido sem ele)",
@@ -8359,7 +8479,33 @@
"offhostLastUpload": "O dump mais recente no bucket é de {when}.",
"offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda."
},
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam."
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam.",
"target": {
"title": "Restaurar em outra conta",
"description": "{file} pertence a uma conta que não está mais neste servidor. Escolha a conta para onde vão os registros. Uma restauração substitui os registros de uma conta, então só uma conta sem registros pode recebê-los. O arquivo é enviado mais uma vez.",
"choiceLabel": "Para onde vão os registros",
"newAccount": "Conta nova",
"existingAccount": "Conta existente",
"usernameLabel": "Nome de usuário da conta nova",
"usernameInvalid": "De 3 a 30 letras, números, _ ou -, sem começar com “managed-”.",
"newAccountHint": "A conta é criada sem senha e sem e-mail. Antes de entregá-la, defina a primeira senha com Redefinir senha em Usuários.",
"accountLabel": "Conta",
"accountPlaceholder": "Escolha uma conta",
"checking": "Verificando a conta…",
"eligible": "Esta conta ainda não tem registros e pode receber a cópia.",
"confirmLabel": "Digite {username} para confirmar",
"submit": "Enviar para esta conta",
"submitting": "Enviando…",
"success": "Backup guardado para {username} ({count} registros). Restaure-o pela linha dele.",
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
"failureAdminAccount": "Esta é uma conta de administrador. Os registros de outra pessoa não podem ir para ela, então escolha uma conta comum ou crie uma nova.",
"failureNotFound": "Esta conta não existe mais.",
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
"failureUsernameTaken": "Este nome de usuário já está em uso.",
"failureOwnerExists": "A conta a que este arquivo pertence está neste servidor, e o arquivo é restaurado nessa conta. Envie-o de novo sem escolher uma conta."
}
},
"danger-zone": {
"title": "Zona de perigo",
@@ -9243,6 +9389,13 @@
"titleFailing": "Os backups estão em risco",
"titleReminder": "Os backups continuam em risco",
"titleRecovered": "Os backups voltaram ao normal"
},
"systemJobAlert": {
"titleFailing": "Tarefa em segundo plano falhando: {queue}",
"titleReminder": "Tarefa em segundo plano ainda falhando: {queue}",
"titleRecovered": "Tarefa em segundo plano funcionando de novo: {queue}",
"bodyFailing": "A tarefa em segundo plano {queue} falhou {failures} vezes seguidas e aparece na página de status da administração. Último erro: {error}",
"bodyRecovered": "A tarefa em segundo plano {queue} voltou a ser executada com sucesso."
}
},
"i18n": {
@@ -9873,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",
@@ -10169,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",
@@ -10342,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}",
@@ -10396,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",
@@ -10423,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": {
@@ -10794,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.",
@@ -10807,7 +11048,7 @@
"resolved": "Resolvidos"
},
"flares": {
"countOne": "1 surto",
"countOne": "{count} surto",
"countOther": "{count} surtos"
},
"timeline": {
@@ -10939,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": {
@@ -11049,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": {
@@ -11250,6 +11491,12 @@
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos os dias das 15h30 às 0h30)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Países Baixos, 24 horas, gratuito, anônimo, também por chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Bélgica, 24 horas, gratuito, anônimo)"
}
},
"instrumentDescription": {
@@ -11846,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": {
@@ -11897,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."
}
@@ -11980,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.",
@@ -12000,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",
@@ -12032,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.",
@@ -12997,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": {
@@ -13174,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": {
@@ -13351,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",
@@ -13418,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",
@@ -13614,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"
},
@@ -13625,7 +13879,11 @@
"start": "Início",
"end": "Fim",
"seriesCaption": "Médias de valores por período",
"period": "Período"
"period": "Período",
"labsCaption": "Valores de exames por dia",
"marker": "Marcador",
"date": "Data",
"value": "Valor"
},
"selection": {
"empty": "Nenhuma entrada neste período",
@@ -13643,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": {
@@ -13667,9 +13925,35 @@
"apply": "Aplicar"
},
"item": {
"labValuesOne": "1 valor laboratorial",
"labValuesOne": "{count} valor laboratorial",
"labValuesFew": "{count} valores laboratoriais",
"labValuesOther": "{count} valores laboratoriais"
},
"labs": {
"menuTitle": "Valores de exames",
"otherPanel": "Outros",
"full": "Até {max} valores de exames ao mesmo tempo. Remova um para adicionar outro.",
"above": "acima da faixa",
"below": "abaixo da faixa",
"reference": "referência {range}",
"report": "Laudo: {title}",
"reportUntitled": "Abrir o laudo",
"excludedOne": "{name}: {count} valor em outra unidade não aparece no gráfico",
"excludedFew": "{name}: {count} valores em outra unidade não aparecem no gráfico",
"excludedOther": "{name}: {count} valores em outra unidade não aparecem no gráfico"
},
"legendLabValue": "Valor de exame, como foi medido",
"legendLabOutside": "Fora da 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": {
@@ -14016,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.0",
"version": "1.47.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -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",
+5
View File
@@ -310,6 +310,11 @@ export default defineConfig({
// other. It checks the phone width by resizing inside the desktop
// project.
"score-history.spec.ts",
// The header-height sweep moves its account's language through every
// locale; a second project would switch it under the first between
// the switch and the measurement. It sets the 390 px phone width
// itself inside the desktop project.
"header-height-locales.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
+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,24 @@
-- System job alert: what the admins were last told about a background job of
-- the instance that keeps failing.
--
-- `system_job_alert_notices` holds, per pg-boss queue, the state the admins
-- were last notified about and when. It is the whole memory of the hourly
-- evaluation (`src/lib/jobs/system-job-alert.ts`): a queue that has failed
-- three runs in a row notifies once, is reminded at most once a day while it
-- keeps failing, and its first successful run after that is announced once.
-- A row exists only for a queue something was announced about.
--
-- One new table, nothing else touched, so the migration takes no lock on an
-- existing table and running it again changes nothing.
--
-- Reversibility: dropping the table loses only the memory of what was
-- announced; the next evaluation announces a still-failing queue once more.
CREATE TABLE IF NOT EXISTS "system_job_alert_notices" (
"queue" TEXT NOT NULL,
"notified_state" TEXT,
"notified_at" TIMESTAMP(3),
CONSTRAINT "system_job_alert_notices_pkey" PRIMARY KEY ("queue"),
CONSTRAINT "system_job_alert_notices_state_check"
CHECK ("notified_state" IS NULL OR "notified_state" IN ('ok', 'failing'))
);
@@ -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$;
+126 -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")
@@ -5958,6 +5996,23 @@ model RecoveryReadinessNotice {
@@map("recovery_readiness_notices")
}
/// Per background queue of the instance, the state the admins were last
/// notified about. The hourly system job evaluation compares a queue's run
/// history against it: three failed runs in a row notify once, a queue still
/// failing is reminded at most once a day, and its first successful run after
/// that is announced once. The update is a compare-and-set on both columns, so
/// two workers never send the same notice. Written and read by
/// `notifySystemJobFailures`.
model SystemJobAlertNotice {
/// The pg-boss queue name (`rollup-full-backfill`, …).
queue String @id
/// `ok` or `failing`; null once a queue's history has aged out.
notifiedState String? @map("notified_state")
notifiedAt DateTime? @map("notified_at")
@@map("system_job_alert_notices")
}
/// The recovery kit an admin confirmed: every key a restore needs, by kind,
/// id and fingerprint, never the key itself. The kit is due again as soon as
/// a key it needs is not in this set (a rotation, a new backup key). Written
@@ -8279,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.0";
/* @sw-version-fallback */ "v1.47.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+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.
+19 -19
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": 94928,
"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,20 +17,20 @@
"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/timeline/timeline-chart.tsx": 1174,
"components/timeline/timeline-geometry.ts": 1395,
"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/codex-client.ts": 1045,
"lib/ai/prompts/insight-generator.ts": 1110,
"lib/ai/provider.ts": 1632,
"lib/ai/coach/stream-events.ts": 811,
"lib/ai/coach/types.ts": 1008,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1178,
@@ -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,23 +62,23 @@
"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/measurements/dense-intraday-retention.ts": 1057,
"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,
"lib/medications/scheduling/recurrence.ts": 1100,
"lib/medications/scheduling/slot-upsert.ts": 912,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/sync.ts": 900,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/rollups/measurement-rollups.ts": 1302,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 806,
"lib/support/report.ts": 903,
@@ -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,30 +1,29 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 346,
"total": 112297,
"count": 344,
"total": 111520,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
"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/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"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)": 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,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 262,
"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,
@@ -62,7 +61,7 @@
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 626,
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
@@ -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,
@@ -217,7 +215,7 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
@@ -232,10 +230,9 @@
"components/targets/range-bar.tsx :: RangeBar": 220,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/timeline/series-point-tips.tsx :: SeriesPointTips": 212,
"components/timeline/timeline-chart.tsx :: ChartBody": 243,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274,
"components/timeline/timeline-view.tsx :: TimelineView": 501,
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
"components/timeline/timeline-view.tsx :: TimelineView": 486,
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
@@ -244,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,
@@ -285,23 +282,23 @@
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
"lib/google-health/data-points.ts :: forEachDataPointPage": 288,
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453,
"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,
@@ -316,12 +313,12 @@
"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": 537,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource": 447,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay": 373,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource": 448,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay": 374,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
@@ -336,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,
@@ -348,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": 499
"proxy.ts :: proxy": 471
}
}
+3 -4
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": 524,
"total": 5488,
"count": 523,
"total": 5476,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -337,7 +337,6 @@
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
"lib/auth/__tests__/oidc.test.ts": 5,
"lib/auth/__tests__/passkey.test.ts": 7,
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
"lib/auth/__tests__/profile-update.test.ts": 4,
@@ -361,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": 2897,
"total": 9236,
"count": 2892,
"total": 9220,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -175,9 +175,9 @@
"app/api/auth/refresh/route.ts": 2,
"app/api/auth/register/__tests__/route.test.ts": 5,
"app/api/auth/register/route.ts": 10,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
"app/api/biomarkers/[id]/route.ts": 6,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/route.ts": 4,
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
"app/api/c/[token]/unlock/route.ts": 1,
@@ -187,12 +187,12 @@
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/questions/route.ts": 3,
"app/api/coach/about-me/route.ts": 4,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/route.ts": 1,
"app/api/coach/reminder-suggestions/route.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/route.ts": 1,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
"app/api/coach/suggested-actions/route.ts": 1,
@@ -200,9 +200,9 @@
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
"app/api/consent/ai/latest/route.ts": 3,
"app/api/consent/ai/route.ts": 3,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/route.ts": 4,
"app/api/custom-metrics/[id]/route.ts": 1,
"app/api/custom-metrics/route.ts": 1,
@@ -212,8 +212,8 @@
"app/api/cycle/day-logs/[id]/route.ts": 4,
"app/api/cycle/day-logs/route.ts": 1,
"app/api/cycle/period/route.ts": 4,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/route.ts": 3,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
@@ -223,10 +223,11 @@
"app/api/dashboard/summary/route.ts": 5,
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
"app/api/dashboard/widgets/route.ts": 14,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/devices/[id]/route.ts": 1,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/route.ts": 4,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
"app/api/documents/inbound/[id]/chat/route.ts": 1,
@@ -241,7 +242,6 @@
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
"app/api/documents/inbound/[id]/summary/route.ts": 2,
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/reindex/route.ts": 1,
"app/api/documents/inbound/route.ts": 2,
"app/api/documents/inbound/usage/route.ts": 1,
@@ -260,10 +260,10 @@
"app/api/export/mood/route.ts": 2,
"app/api/family-history/[id]/route.ts": 2,
"app/api/family-history/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fhir/__tests__/observation-series.test.ts": 1,
"app/api/fhir/__tests__/route.test.ts": 1,
"app/api/fhir/metadata/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fitbit/callback/route.ts": 1,
"app/api/fitbit/connect/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
@@ -292,11 +292,6 @@
"app/api/insights/breathing-screening/route.ts": 2,
"app/api/insights/cards/__tests__/route.test.ts": 2,
"app/api/insights/cards/route.ts": 9,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
@@ -308,14 +303,19 @@
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
"app/api/insights/chat/fenced/route.ts": 1,
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
"app/api/insights/chat/route.ts": 9,
"app/api/insights/coach-read/route.ts": 2,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/route.ts": 1,
"app/api/insights/coach/nudge-status/route.ts": 1,
"app/api/insights/coach/seen/route.ts": 1,
@@ -326,9 +326,9 @@
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
"app/api/insights/derived/batch/route.ts": 6,
"app/api/insights/derived/route.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/route.ts": 2,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/route.ts": 1,
"app/api/insights/feedback/route.ts": 2,
"app/api/insights/generate/__tests__/route.test.ts": 12,
@@ -367,8 +367,8 @@
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
"app/api/internal/web-vitals/route.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/ocr/capability/route.ts": 1,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
"app/api/labs/ocr/commit/route.ts": 4,
@@ -388,13 +388,13 @@
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
"app/api/measurement-reminders/route.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
@@ -411,6 +411,7 @@
"app/api/measurements/series-batch/route.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 5,
"app/api/measurements/series/route.ts": 6,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/[id]/__tests__/route.test.ts": 8,
"app/api/medications/[id]/api-endpoint/route.ts": 2,
"app/api/medications/[id]/cadence/route.ts": 10,
@@ -422,10 +423,10 @@
"app/api/medications/[id]/dose-history/route.ts": 14,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
"app/api/medications/[id]/glp1/route.ts": 8,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
"app/api/medications/[id]/intake/import/route.ts": 6,
@@ -433,12 +434,11 @@
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
"app/api/medications/[id]/inventory/route.ts": 4,
"app/api/medications/[id]/phase-config/route.ts": 4,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 5,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/categories/[key]/route.ts": 1,
"app/api/medications/categories/route.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 2,
@@ -455,9 +455,9 @@
"app/api/medications/route.ts": 2,
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
"app/api/mental-health/assessments/route.ts": 6,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
"app/api/mood-entries/[id]/route.ts": 22,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk-delete/route.ts": 4,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
@@ -547,12 +547,12 @@
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/connect/route.ts": 3,
"app/api/withings/status/route.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/route.ts": 2,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/batch/route.ts": 3,
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
@@ -654,20 +654,20 @@
"app/loading.tsx": 2,
"app/mcp/__tests__/route.test.ts": 5,
"app/measurements/page.tsx": 5,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/__tests__/category-filter.test.tsx": 2,
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
"app/medications/__tests__/page-prefetch.test.tsx": 1,
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/new/page.tsx": 1,
"app/medications/page-client.tsx": 50,
"app/mental-wellbeing/page.tsx": 1,
"app/mood/page-client.tsx": 2,
"app/not-found.tsx": 2,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/handover/page.tsx": 1,
"app/onboarding/page.tsx": 2,
"app/privacy/__tests__/page.test.tsx": 1,
@@ -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,
@@ -1741,8 +1739,7 @@
"lib/ai/coach/turn/types.ts": 17,
"lib/ai/coach/types.ts": 58,
"lib/ai/coach/visits-snapshot.ts": 1,
"lib/ai/codex-client.ts": 18,
"lib/ai/codex-slug-cache.ts": 1,
"lib/ai/codex-client.ts": 17,
"lib/ai/consent-guard.ts": 1,
"lib/ai/feedback-attribution.ts": 2,
"lib/ai/insight-interpretation.ts": 1,
@@ -1961,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,
@@ -2067,7 +2063,6 @@
"lib/google-health/sync-metrics.ts": 1,
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
"lib/i18n/config.ts": 1,
"lib/i18n/context.tsx": 1,
"lib/i18n/relative-time.ts": 4,
"lib/i18n/shared-resolve.ts": 1,
"lib/i18n/source-labels.ts": 1,
@@ -2459,7 +2454,7 @@
"lib/measurements/cumulative-day-sum.ts": 6,
"lib/measurements/daily-series-read.ts": 2,
"lib/measurements/dense-intraday-hourly-rebuild.ts": 1,
"lib/measurements/dense-intraday-retention.ts": 4,
"lib/measurements/dense-intraday-retention.ts": 3,
"lib/measurements/display-transform.ts": 6,
"lib/measurements/drain-per-sample-cumulative.ts": 8,
"lib/measurements/import-apple-health-export.ts": 5,
@@ -2748,7 +2743,7 @@
"lib/rollups/measurement-coverage.ts": 1,
"lib/rollups/measurement-read-wmy.ts": 4,
"lib/rollups/measurement-read.ts": 10,
"lib/rollups/measurement-rollups.ts": 18,
"lib/rollups/measurement-rollups.ts": 12,
"lib/rollups/mood-rollups.ts": 4,
"lib/rollups/tiered-context.ts": 6,
"lib/score-config/rows.ts": 1,
@@ -2897,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 ─────────────────────────────────────────────────────────
{
@@ -679,6 +687,17 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/admin/delete-user-control.tsx",
destroys:
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
triggers: ["components/admin/user-management-section.tsx"],
// The undo route exists and the row offers it until the purge date
// (`pending-deletion-controls.tsx`); with the window at 0 there is
// nothing to undo, and the dialog says so.
recovery: "restorable",
confirm: ["ConfirmButton"],
},
// ── Integrations ────────────────────────────────────────────────────────
// Every connection renders the shared integration card, whose three-dot
@@ -708,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:
Loaded 100 of 1486 files, more files were not shown because too many files have changed in this diff. Show more