mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eadcdc326 | ||
|
|
5e1c39438f | ||
|
|
ce32b0ef89 | ||
|
|
73b9544f83 | ||
|
|
5793db9f2a | ||
|
|
cef48d20f0 | ||
|
|
a91689a0be | ||
|
|
bee4ac4f73 | ||
|
|
8eb2fb773f | ||
|
|
84dace4f89 | ||
|
|
879c92f6ee | ||
|
|
4921188f22 | ||
|
|
2adea7052b | ||
|
|
31997e3232 | ||
|
|
aea9796170 | ||
|
|
78611277fe | ||
|
|
46a1a5d320 | ||
|
|
01884e91fa | ||
|
|
126bab8c00 | ||
|
|
c7b3f6e4d0 | ||
|
|
24dd8a28e3 | ||
|
|
6c5146942b | ||
|
|
2aa98bb6c2 | ||
|
|
a605a7223f | ||
|
|
74e691cecd | ||
|
|
be7a65bdb9 | ||
|
|
a74d2df3c5 | ||
|
|
2522a784f9 | ||
|
|
3b3ef5c0a1 | ||
|
|
e8ef590af7 | ||
|
|
d9a31524a2 | ||
|
|
ac31ac40b5 | ||
|
|
2480a0138d | ||
|
|
c0470df14c | ||
|
|
c96e0e0f78 | ||
|
|
65f1a3db91 | ||
|
|
910f28dcfe | ||
|
|
92638ec089 | ||
|
|
e2170115c7 | ||
|
|
2e14b2d8b5 | ||
|
|
53e5b47e0d | ||
|
|
0882ebd79f | ||
|
|
7b4c67b372 | ||
|
|
da3d29424d | ||
|
|
f39598f2e7 | ||
|
|
2e28d4ed4c | ||
|
|
63f9d2f60e | ||
|
|
ef61c7d5d1 | ||
|
|
e9886ee554 | ||
|
|
6800ff2620 | ||
|
|
e9ae024035 | ||
|
|
341061de5c | ||
|
|
4894f5be5b | ||
|
|
27a1cd6c2f | ||
|
|
afdb8163ce | ||
|
|
148af765c9 | ||
|
|
a35b77c147 | ||
|
|
a79fc4168a | ||
|
|
59434cd463 | ||
|
|
66533497de | ||
|
|
a812fb699f | ||
|
|
d3401b5b89 | ||
|
|
41f90bac3f | ||
|
|
d98e16557b | ||
|
|
40200b55a7 | ||
|
|
25770912ce | ||
|
|
90865eb485 | ||
|
|
27b054d8c4 | ||
|
|
fb143a2d47 | ||
|
|
bc4b9f4350 | ||
|
|
3149805592 | ||
|
|
849fe4b2c6 | ||
|
|
e547a77683 | ||
|
|
09d932ce2a | ||
|
|
b6b693717c | ||
|
|
5dd6453773 | ||
|
|
de47cb7b31 | ||
|
|
8a89d5cfc3 | ||
|
|
2348073eeb | ||
|
|
83c06051fe | ||
|
|
73f16e5389 | ||
|
|
13e232c836 | ||
|
|
e2ba86b12f | ||
|
|
67909f2d9d | ||
|
|
f266555fe8 | ||
|
|
4762ca43c2 | ||
|
|
a162990d18 | ||
|
|
65dc9751e0 | ||
|
|
1bbd2d97c7 | ||
|
|
1a4703f4fe | ||
|
|
62d1f57de8 | ||
|
|
22f83aed6e | ||
|
|
b93241aa87 | ||
|
|
cac0668119 | ||
|
|
b2fb295d43 | ||
|
|
0007973b78 | ||
|
|
3e2e2ad56b | ||
|
|
2d8b782f34 | ||
|
|
a80fcf5276 | ||
|
|
a10bebdc43 | ||
|
|
41e75b5bde | ||
|
|
da48f1af9e | ||
|
|
7af9ebe480 | ||
|
|
0e7134a2ea | ||
|
|
55a3a9b53d | ||
|
|
42cae738f1 | ||
|
|
a11ea8444d | ||
|
|
5705184857 | ||
|
|
253176d01a | ||
|
|
763d6c600f | ||
|
|
119431dc15 | ||
|
|
b975e86b39 | ||
|
|
144840bab9 | ||
|
|
7cb67c2e75 | ||
|
|
a598b00081 | ||
|
|
1ac69420d5 | ||
|
|
9a3140a4d3 | ||
|
|
fa439601c4 | ||
|
|
b1cb387062 | ||
|
|
3547c4bd2f | ||
|
|
394828449a | ||
|
|
df6d20b696 | ||
|
|
c47f61f02a | ||
|
|
d1ab195b43 | ||
|
|
a503c8855d | ||
|
|
5d36afb487 | ||
|
|
2c33c2a0a0 | ||
|
|
d7046c9737 | ||
|
|
713806fe9b | ||
|
|
899a054e64 | ||
|
|
66709222b2 | ||
|
|
52e6bcd480 | ||
|
|
ba27e728cf | ||
|
|
691c2e7301 | ||
|
|
cd344125d5 | ||
|
|
377e0d0d3a | ||
|
|
cf7905dc92 | ||
|
|
227c80878c | ||
|
|
4f7802ba28 | ||
|
|
54d0647a6d | ||
|
|
270c6ca619 | ||
|
|
d8743e9111 | ||
|
|
3d0ae05192 | ||
|
|
de7858100f | ||
|
|
65e35761ec | ||
|
|
775933b7d5 | ||
|
|
3d1a6224ba | ||
|
|
df1e19f1c2 | ||
|
|
c7ff22ab00 | ||
|
|
485161a657 | ||
|
|
95525a0259 | ||
|
|
f05a112c61 | ||
|
|
b6da360856 | ||
|
|
381726f471 | ||
|
|
8643a1ab8e | ||
|
|
a3516160c0 | ||
|
|
5aac158b12 | ||
|
|
540f4ff84f | ||
|
|
c6372bde94 | ||
|
|
2c8140a502 | ||
|
|
1bde3fc618 | ||
|
|
48bfbad4e6 | ||
|
|
94da0bc352 | ||
|
|
165120e845 | ||
|
|
49a0eb92ff | ||
|
|
6a8ac98613 | ||
|
|
70cc753c49 | ||
|
|
ee0adae99c | ||
|
|
0115a02d22 | ||
|
|
80a62afbbf | ||
|
|
a822aa5a0e | ||
|
|
2c6fb82326 | ||
|
|
e605fe0ae8 | ||
|
|
f9431da64f | ||
|
|
5405ce053d | ||
|
|
79ac5670c7 | ||
|
|
cae6edec64 | ||
|
|
5f9c8461a3 | ||
|
|
34302867aa | ||
|
|
ea7c6c860e | ||
|
|
a2ce11fec4 | ||
|
|
7dfc2cbbea | ||
|
|
e7c625cab7 | ||
|
|
0810285061 | ||
|
|
e3aae483b0 | ||
|
|
6b9d222643 | ||
|
|
577e43004f | ||
|
|
773b943bde | ||
|
|
9a6d9b6793 | ||
|
|
1c4bcce6c7 | ||
|
|
022c6de0b5 | ||
|
|
defa43d46c | ||
|
|
2d1f3fef1e | ||
|
|
3d5d5fc4ff | ||
|
|
779097933e | ||
|
|
c66ffa9914 | ||
|
|
beb7187c2b | ||
|
|
717eb1d1e1 | ||
|
|
087c8868cf | ||
|
|
369c063701 | ||
|
|
b42b0d5fee | ||
|
|
5306ba7614 |
No files matched your search
@@ -38,6 +38,13 @@
|
||||
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
|
||||
# Left commented here so that append does not create a duplicate key. Uncomment
|
||||
# and set it directly instead if you prefer to edit the file by hand.
|
||||
|
||||
# The container runs the app as `healthlog_app_<database>_<hash>`, a database
|
||||
# role it creates on every start that reads and writes rows but cannot change
|
||||
# the schema. The
|
||||
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
|
||||
# you manage database roles yourself (docs/self-hosting/database-role.md).
|
||||
# DB_APP_ROLE="off"
|
||||
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
|
||||
|
||||
|
||||
|
||||
+17
-5
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@ name: Trivy Scheduled Scan
|
||||
# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every
|
||||
# push to `main` and once a day, blocking on HIGH/CRITICAL.
|
||||
# - `security.yml` → `docker-security` builds the image on a push to `main`
|
||||
# and runs `trivy image` against it, blocking on HIGH/CRITICAL.
|
||||
# and runs `trivy image` against it, blocking on HIGH/CRITICAL with a fix.
|
||||
#
|
||||
# Both are change-triggered, and both answer "is this change safe". Neither
|
||||
# answers "is what we shipped last week still safe", because an advisory
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
skip-dirs: src/generated
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
sarif_file: trivy-filesystem.sarif
|
||||
category: trivy-filesystem
|
||||
@@ -142,10 +142,17 @@ jobs:
|
||||
output: trivy-published-image.sarif
|
||||
severity: "CRITICAL,HIGH,MEDIUM"
|
||||
limit-severities-for-sarif: true
|
||||
# Only findings with a published fix reach code scanning. Debian
|
||||
# leaves many base-package advisories unfixed in stable (no-dsa,
|
||||
# postponed), and nothing in this repository can act on those: the
|
||||
# build's `apt-get upgrade` takes the fix the day Debian ships one,
|
||||
# and that is when the alert should appear. Listing them all made
|
||||
# the page a hundred-odd rows deep without one to act on.
|
||||
ignore-unfixed: true
|
||||
exit-code: "0"
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
sarif_file: trivy-published-image.sarif
|
||||
category: trivy-published-image
|
||||
@@ -162,3 +169,6 @@ jobs:
|
||||
format: table
|
||||
severity: "CRITICAL"
|
||||
exit-code: "1"
|
||||
# Same rule as the blocking gate in security.yml and the SARIF
|
||||
# report above: only a finding with a published fix counts.
|
||||
ignore-unfixed: true
|
||||
@@ -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
|
||||
|
||||
+375
@@ -1,5 +1,380 @@
|
||||
# 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
|
||||
|
||||
+208
-74
@@ -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"]
|
||||
@@ -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
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file. v1.47.0 raised maxCatalogKbGz from 210 to 250 for the Russian catalog: Cyrillic is two bytes per letter in UTF-8, so the same key set is 1.12 MB raw against about 0.8 MB for the Latin-script locales, 240 KB gz as an emitted chunk where the largest Latin one is under 210. A reader downloads one catalog, once; nothing else in the budget moved.",
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"firstLoadP95KbGz": 447,
|
||||
"firstLoadMaxKbGz": 461,
|
||||
"uniqueClientKbGz": 1930,
|
||||
"maxRechartsChunks": 1,
|
||||
"maxCatalogKbGz": 210,
|
||||
"maxCatalogKbGz": 250,
|
||||
"routeBaselineKbGz": {
|
||||
"/_global-error/page": 133,
|
||||
"/_not-found/page": 282,
|
||||
|
||||
+11
-3
@@ -81,6 +81,12 @@ x-healthlog-env: &healthlog_env
|
||||
# 1 sends neither of the two settings above (they ride the libpq `options`
|
||||
# startup parameter, which PgBouncer refuses by default).
|
||||
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
|
||||
# The app connects as `healthlog_app_<database>_<hash>`, a role the
|
||||
# entrypoint creates for this database that reads and writes rows but
|
||||
# cannot change the schema; the migrations and the queue's install keep
|
||||
# the owner above. off keeps the app on the owner (for an operator who
|
||||
# manages database roles). See docs/self-hosting/database-role.md.
|
||||
DB_APP_ROLE: "${DB_APP_ROLE:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
|
||||
# set it when the URL registered with Withings differs from the app's own
|
||||
@@ -557,8 +563,10 @@ services:
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@@ -567,7 +575,7 @@ services:
|
||||
# past that, so a boot storm that saturates the pool for a minute after
|
||||
# the start period (backfills, a restore, another tenant's import) costs
|
||||
# a probe or two, not the container's health and the proxy's routing.
|
||||
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
|
||||
# The probe's own 4 s abort ends the request inside Docker's 5 s, so a
|
||||
# stalled connection reads as one clean miss rather than a killed probe.
|
||||
# A database that stays away for 2.5 minutes is a real outage and shows.
|
||||
retries: 5
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -110,6 +110,14 @@ What `search` matches for the clinical records:
|
||||
reading deferred is matched on its title and details only.
|
||||
- `vaccination:<id>`: vaccine name, catalogue name, practitioner.
|
||||
|
||||
A lab has two ids. `lab:<analyte>` is the analyte view and answers with that
|
||||
marker's latest reading. `labresult:<id>` is one reading: its value or text
|
||||
result, unit, reference range, status, date, and its own links (the report
|
||||
it was read from, the visits it is filed against). Every link to a lab result
|
||||
(from a document, a visit, a condition or a medication) uses the reading id,
|
||||
so following a report's link lands on the reading that report holds rather
|
||||
than on a later draw. Each reading in `get_labs` carries the same id.
|
||||
|
||||
Condition and vaccination notes are never searched or returned, the same
|
||||
rule the Coach and the doctor report follow. A deleted practitioner is not
|
||||
named or matched. Query words shorter than four letters match whole words or
|
||||
|
||||
+3134
-859
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -160,7 +160,7 @@ test.describe("Settings → Export & Import", () => {
|
||||
).toContainText("2/12");
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-cardio-export"),
|
||||
).toContainText("2/15");
|
||||
).toContainText("2/16");
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-mobility-export"),
|
||||
).toContainText("0/11");
|
||||
|
||||
+94
-1
@@ -18,6 +18,9 @@
|
||||
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
|
||||
* single readings with the range behind them, read on its own, kept
|
||||
* across a reload, and listed on its lab day on a phone.
|
||||
* 8. A symptom (#981) is picked from the values menu, drawn as a value
|
||||
* line on the fixed 0 to 10 scale, read with the days behind a point,
|
||||
* kept across a reload, and named in the phone chronicle.
|
||||
*
|
||||
* The timeline's server routes are mocked with the contract's shapes
|
||||
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
|
||||
@@ -865,8 +868,18 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
|
||||
const target = page.locator(
|
||||
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
|
||||
);
|
||||
await target.focus();
|
||||
const tip = page.locator('[data-slot="timeline-point-tip"]');
|
||||
// A pointer resting on another line's point, as the menu click can leave
|
||||
// it: the focus takes the card, and that point losing the pointer (the
|
||||
// focus scrolls the chart from under it) must not close the card.
|
||||
await page
|
||||
.locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]')
|
||||
.last()
|
||||
.hover();
|
||||
await expect(tip).toHaveAttribute("data-series", "WEIGHT");
|
||||
await target.focus();
|
||||
await expect(tip).toHaveAttribute("data-t", "fe-1");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(tip).toHaveAttribute("data-t", "fe-1");
|
||||
await expect(tip).toContainText("22 ng/mL");
|
||||
await expect(tip).toContainText("Laborbefund März");
|
||||
@@ -909,6 +922,86 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
|
||||
await expect(labs).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setTimelineModuleOn(page);
|
||||
const log = await mockTimeline(page, "full", TODAY, { symptoms: true });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/timeline?zoom=all");
|
||||
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page
|
||||
.locator(
|
||||
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
|
||||
)
|
||||
.click();
|
||||
const menu = page.locator('[data-slot="timeline-values-menu"]');
|
||||
await expect(
|
||||
menu.locator('[data-slot="timeline-values-symptoms"]'),
|
||||
).toBeVisible();
|
||||
// A symptom hidden from entry is still offered, and says so.
|
||||
await expect(
|
||||
menu.locator(
|
||||
'[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
await menu
|
||||
.locator(
|
||||
'[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]',
|
||||
)
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
const row = page.locator('g[data-series="symptom:sd-headache"]');
|
||||
const points = row.locator('[data-slot="timeline-series-point"]');
|
||||
await expect(points).toHaveCount(3);
|
||||
// The quarter resting on two days is hollow.
|
||||
await expect(points.nth(1)).toHaveAttribute("data-thin", "true");
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-legend-symptom"]'),
|
||||
).toBeVisible();
|
||||
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
|
||||
"symptom:sd-headache",
|
||||
);
|
||||
|
||||
const target = page.locator(
|
||||
'[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]',
|
||||
);
|
||||
const tip = page.locator('[data-slot="timeline-point-tip"]');
|
||||
await target.focus();
|
||||
await expect(tip).toHaveAttribute("data-t", "2024-01-01");
|
||||
// Out of 10 in the account's own decimal mark, with the days behind it.
|
||||
await expect(tip).toContainText(/6[.,]5\/10/);
|
||||
await expect(tip).toContainText(/\b9\b/);
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="timeline-page"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
|
||||
|
||||
// The choice is kept: a reload asks for the symptom again.
|
||||
await page.reload();
|
||||
await expect(points).toHaveCount(3, { timeout: 20_000 });
|
||||
|
||||
// On a phone: its means in the chronicle, no sideways scroll.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const means = page.locator(
|
||||
'[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]',
|
||||
);
|
||||
await expect(means.first()).toBeVisible();
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("the timeline page ends where its content does, at every desktop width", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -514,6 +514,8 @@ export function fullTimeline(today: string) {
|
||||
availableSeries: FULL_AVAILABLE,
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
@@ -610,6 +612,50 @@ function labsLane(keys: readonly string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two of the person's own symptoms (#981): a headache logged over several
|
||||
* quarters, one quarter resting on two days, and a nausea hidden from entry
|
||||
* since, logged once. Their points are bucket means of day peaks on the
|
||||
* fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all`
|
||||
* view of the full record uses.
|
||||
*/
|
||||
export const SYMPTOM_OPTIONS = [
|
||||
{
|
||||
key: "symptom:sd-headache",
|
||||
definitionId: "sd-headache",
|
||||
label: "Kopfschmerz",
|
||||
icon: "brain",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
key: "symptom:sd-nausea",
|
||||
definitionId: "sd-nausea",
|
||||
label: "Übelkeit",
|
||||
icon: null,
|
||||
isActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SYMPTOM_QUARTERS: Record<
|
||||
string,
|
||||
Array<{ t: string; mean: number; count: number }>
|
||||
> = {
|
||||
"symptom:sd-headache": [
|
||||
{ t: "2024-01-01", mean: 6.5, count: 9 },
|
||||
{ t: "2024-04-01", mean: 4, count: 2 },
|
||||
{ t: "2025-01-01", mean: 3.25, count: 5 },
|
||||
],
|
||||
"symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }],
|
||||
};
|
||||
|
||||
function symptomLines(keys: readonly string[], bucket: Bucket) {
|
||||
return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({
|
||||
...o,
|
||||
scale: { min: 0, max: 10 },
|
||||
points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function readyTimeline(today: string) {
|
||||
return {
|
||||
zoom: "all",
|
||||
@@ -733,6 +779,8 @@ export function readyTimeline(today: string) {
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
@@ -935,6 +983,8 @@ export async function mockTimeline(
|
||||
dataFrom?: string;
|
||||
/** The record has lab markers to offer and draw (#1299). */
|
||||
labs?: boolean;
|
||||
/** The record has logged symptoms to offer and draw (#981). */
|
||||
symptoms?: boolean;
|
||||
} = {},
|
||||
): Promise<TimelineMockLog> {
|
||||
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
|
||||
@@ -950,7 +1000,11 @@ export async function mockTimeline(
|
||||
.filter(Boolean);
|
||||
// A lab marker is a line of its own (`labSeries`), never a mean.
|
||||
const labKeys = named.filter((key) => key.startsWith("lab:"));
|
||||
const values = named.filter((key) => !key.startsWith("lab:"));
|
||||
// A symptom is a line of its own too (`symptomSeries`), on 0 to 10.
|
||||
const symptomKeys = named.filter((key) => key.startsWith("symptom:"));
|
||||
const values = named.filter(
|
||||
(key) => !key.startsWith("lab:") && !key.startsWith("symptom:"),
|
||||
);
|
||||
const labs =
|
||||
state === "full" && options.labs
|
||||
? {
|
||||
@@ -966,6 +1020,13 @@ export async function mockTimeline(
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const bucket = bucketFor(zoom, from, to);
|
||||
const symptoms =
|
||||
state === "full" && options.symptoms
|
||||
? {
|
||||
symptomSeries: symptomLines(symptomKeys, bucket),
|
||||
availableSymptoms: SYMPTOM_OPTIONS,
|
||||
}
|
||||
: {};
|
||||
const dataFrom =
|
||||
options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01");
|
||||
if (options.onlyOld && (zoom === "quarter" || zoom === "year")) {
|
||||
@@ -988,6 +1049,7 @@ export async function mockTimeline(
|
||||
return json(route, 200, {
|
||||
...body,
|
||||
...labs,
|
||||
...symptoms,
|
||||
zoom,
|
||||
bucket,
|
||||
...(chosen
|
||||
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Herzfrequenzvariabilität",
|
||||
"typeRestingHeartRate": "Ruheherzfrequenz",
|
||||
"typeActiveEnergyBurned": "Aktive Energie",
|
||||
"typeBasalEnergyBurned": "Ruheenergie",
|
||||
"typeFlightsClimbed": "Treppen",
|
||||
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
|
||||
"typeVo2Max": "VO₂max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} Min.",
|
||||
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
|
||||
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stresslevel",
|
||||
"typeTrainingReadiness": "Trainingsbereitschaft",
|
||||
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Geplant",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Quelle",
|
||||
"intakeHistoryInjectionSite": "Injektionsstelle: {site}",
|
||||
"intakeHistoryStatusTaken": "Eingenommen",
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "Punkte",
|
||||
"unitMinutes": "Min.",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "Belastung"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Schlafqualität",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
|
||||
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
|
||||
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet.",
|
||||
"bodyBatteryExplainer": "Deine Energiereserve auf Garmins Skala von 0 bis 100, der höchste Stand des Tages. Ruhe und Schlaf laden sie auf, Stress und Bewegung zehren davon.",
|
||||
"trainingReadinessExplainer": "Wie bereit dein Körper für eine harte Einheit ist, auf Garmins Skala von 0 bis 100. Der Wert kommt über eine von dir eingerichtete Brücke, weil Garmin ihn nicht direkt teilt.",
|
||||
"stressLevelExplainer": "Garmins durchschnittliches Stresslevel des Tages auf einer Skala von 0 bis 100, abgeleitet aus deiner Herzfrequenzvariabilität. Niedriger heißt ruhiger.",
|
||||
"acuteTrainingLoadExplainer": "Garmins akute Trainingsbelastung: die gewichtete Belastung deiner Aktivitäten der letzten sieben Tage. Der Wert kommt über eine von dir eingerichtete Brücke. Vergleiche ihn mit deinen eigenen letzten Wochen."
|
||||
},
|
||||
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "In diesem Zeitraum wurde noch kein Score erfasst.",
|
||||
"usualRange": "Schattiert: dein üblicher Bereich, {lo} bis {hi}.",
|
||||
"seam": "Die Linie bricht dort ab, wo sich geändert hat, was in den Score einfließt."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Nach Tageszeit",
|
||||
"description": "Dein durchschnittlicher Blutdruck je Tageszeit im Zeitraum des Diagramms.",
|
||||
"part": {
|
||||
"morning": "Morgens",
|
||||
"midday": "Mittags",
|
||||
"evening": "Abends"
|
||||
},
|
||||
"filterAll": "Ganzer Tag",
|
||||
"filterLabel": "Im Diagramm zeigen",
|
||||
"readingsOne": "{count} Messung",
|
||||
"readingsFew": "{count} Messungen",
|
||||
"readingsOther": "{count} Messungen",
|
||||
"notEnough": "Zu wenige Messungen",
|
||||
"pulse": "Puls {value} bpm",
|
||||
"difference": "Morgens minus abends: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Morgen und Abend werden verglichen, sobald beide mindestens {min} Messungen haben.",
|
||||
"hours": "Morgens {morning}, mittags {midday}, abends {evening}, in deiner Zeitzone.",
|
||||
"hourRange": "{start} bis {end} Uhr",
|
||||
"emptyTitle": "Keine vollständigen Messungen in diesem Zeitraum",
|
||||
"emptyDescription": "Hier erscheinen Messungen mit systolischem und diastolischem Wert.",
|
||||
"error": "Die Werte nach Tageszeit konnten nicht geladen werden.",
|
||||
"chartTitle": {
|
||||
"morning": "Blutdruck morgens",
|
||||
"midday": "Blutdruck mittags",
|
||||
"evening": "Blutdruck abends"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "morgens",
|
||||
"midday": "mittags",
|
||||
"evening": "abends"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Wird von der iOS-App synchronisiert oder aus einem Export importiert.",
|
||||
"file": "Wird unter Einstellungen importiert, sobald du hier fertig bist."
|
||||
"file": "Wird unter Einstellungen importiert, sobald du hier fertig bist.",
|
||||
"fitbit": "Wird jetzt über Google Health verbunden."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Noch nicht verbunden.",
|
||||
"openSettings": "Einstellungen → Integrationen öffnen",
|
||||
"noDate": "unbekannt",
|
||||
"throughGoogleHealth": "{source}-Daten kommen jetzt über {target}: Die klassische {source}-Verbindung wurde abgeschaltet. Verbinde {target} mit dem Google-Konto, an dem dein {source} angemeldet ist, dann kommen deine Werte von dort.",
|
||||
"needsCredentials": {
|
||||
"title": "Diese Instanz hat noch keine {target}-App hinterlegt.",
|
||||
"detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Schlafdefizit",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stresslevel",
|
||||
"trainingReadiness": "Trainingsbereitschaft",
|
||||
"acuteTrainingLoad": "Akute Trainingsbelastung"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Externes Gerät",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Uhr",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} Datentypen, neuestes {relative}",
|
||||
"quiet": "{count} still",
|
||||
"workouts": "Workouts",
|
||||
"medicationDoses": "Medikamenteneinnahmen"
|
||||
"medicationDoses": "Medikamenteneinnahmen",
|
||||
"cycleDays": "Zyklustage",
|
||||
"cycleDaysCount": "Zyklustage ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte (Body Battery, Training Load, HRV-Status, VO₂max) werden von Garmin an keine der beiden Plattformen weitergegeben.",
|
||||
"docsLink": "Zur Anleitung"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glukose-Referenzband",
|
||||
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
|
||||
"deleteAccountScheduled": "Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
|
||||
"dangerZoneSuccessGrace": "Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
|
||||
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
|
||||
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Füge einen Garmin-Personal-API-Schlüssel ein, um Schlaf, HRV, Body Battery, Stress, Schritte, Gewicht und Aktivitäten aus Garmin Connect zu synchronisieren.",
|
||||
"garminSync": "Jetzt synchronisieren",
|
||||
"garminSyncResult": "{count} Einträge synchronisiert",
|
||||
"garminSyncFailed": "Synchronisierung fehlgeschlagen",
|
||||
"garminViewData": "Erholung ansehen",
|
||||
"garminReplaceKey": "Schlüssel ersetzen",
|
||||
"garminDisconnect": "Trennen",
|
||||
"garminDisconnectTitle": "Garmin trennen?",
|
||||
"garminDisconnectDescription": "Künftige Synchronisierungen enden, und der Schlüssel wird von diesem Server entfernt. Bereits importierte Daten bleiben in deinem Verlauf. Bei Garmin bleibt der Schlüssel gültig, bis du ihn dort widerrufst.",
|
||||
"garminKeyValidUntil": "Schlüssel gültig bis {date}",
|
||||
"garminKeyExpired": "Dein Garmin-Schlüssel ist am {date} abgelaufen. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung weiterläuft.",
|
||||
"garminKeyExpiresToday": "Dein Garmin-Schlüssel läuft heute ab. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung nicht stoppt.",
|
||||
"garminKeyExpiring": "Dein Garmin-Schlüssel läuft am {date} ab. Erstelle vorher in Garmin Connect einen neuen Schlüssel und füge ihn hier ein.",
|
||||
"garminKeyExpiryUnknown": "Garmin verrät nicht, wann dieser Schlüssel abläuft. Ersetze den Schlüssel und wähle die Gültigkeit, die du in Garmin Connect eingestellt hast, dann warnt dich diese Karte eine Woche vorher.",
|
||||
"garminConnected": "Garmin verbunden. Die erste Synchronisierung läuft an.",
|
||||
"garminKeyHowTo": "Öffne in Garmin Connect die Einstellungen, dann Personal API, und erstelle einen Schlüssel. Garmin zeigt ihn nur einmal an, kopiere ihn also sofort.",
|
||||
"garminKeySettingsLink": "Garmin Connect öffnen",
|
||||
"garminApiKey": "Personal-API-Schlüssel",
|
||||
"garminApiKeyPlaceholder": "Schlüssel einfügen",
|
||||
"garminApiKeySavedPlaceholder": "Gespeichert. Füge einen neuen Schlüssel ein, um ihn zu ersetzen",
|
||||
"garminKeyValidity": "Gewählte Gültigkeit",
|
||||
"garminKeyValidityUnknown": "Weiß ich nicht",
|
||||
"garminKeyValidityDays": "{count} Tage",
|
||||
"garminKeyValidityHelp": "Garmin meldet nicht, wann ein Schlüssel abläuft. Mit der Gültigkeit warnt dich diese Karte eine Woche, bevor der Schlüssel nicht mehr funktioniert.",
|
||||
"garminConnect": "Verbinden",
|
||||
"garminUpdate": "Änderungen speichern",
|
||||
"garminRegion": "Garmin-Dienst",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin hat diesen Schlüssel nicht angenommen. Prüfe, ob er vollständig kopiert ist, zum gewählten Dienst gehört und weder abgelaufen noch widerrufen ist.",
|
||||
"rateLimited": "Garmin begrenzt gerade die Anfragen. Versuche es in ein paar Minuten noch einmal.",
|
||||
"unreachable": "Garmin ist nicht erreichbar. Versuche es in ein paar Minuten noch einmal.",
|
||||
"invalid": "Dieser Schlüssel sieht nicht richtig aus. Füge ihn noch einmal ohne Leerzeichen ein.",
|
||||
"generic": "Das hat nicht geklappt. Versuche es noch einmal."
|
||||
},
|
||||
"garminKeyValidityMenu": "Gültigkeit des Schlüssels",
|
||||
"garminValiditySaved": "Gültigkeit gespeichert.",
|
||||
"garminKeyCreatedOn": "Erstellt am",
|
||||
"garminKeyCreatedOnHelp": "Der Tag, an dem du den Schlüssel in Garmin Connect erstellt hast. Leer gelassen zählt die Gültigkeit ab dem Tag, an dem du ihn hier verbunden hast.",
|
||||
"garminUnavailable": "Garmin stellt für dein Konto diese Daten nicht bereit: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "Tagesübersichten",
|
||||
"activities": "Aktivitäten",
|
||||
"sleep": "Schlaf",
|
||||
"hrv": "HRV",
|
||||
"weight": "Wiegungen",
|
||||
"cycle": "Menstruationszyklus"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
|
||||
"endedPeriodOnDate": "Periode an diesem Tag beendet",
|
||||
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
|
||||
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
|
||||
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da.",
|
||||
"feed": {
|
||||
"title": "Kalender-Abo",
|
||||
"description": "Deine nächsten Perioden als ganztägige Termine in der Kalender-App, die du schon nutzt.",
|
||||
"disclosure": "Dein Kalenderanbieter (Google, iCloud, Outlook) speichert diese Daten auf seinen Servern. Schaltest du das Abo später ab, bleiben Kopien, die er schon hat, dort liegen.",
|
||||
"turnOn": "Abo einschalten",
|
||||
"error": "Das Kalender-Abo konnte nicht geändert werden. Versuch es noch einmal.",
|
||||
"linkHidden": "Ein Link, der mit {prefix} beginnt, ist aktiv. Er wird nur einmal gezeigt, beim Erstellen; erneuere ihn, wenn du einen neuen brauchst.",
|
||||
"linkLabel": "Abo-Link",
|
||||
"linkOnce": "Nur jetzt sichtbar. Füge ihn in deine Kalender-App ein oder erneuere ihn später für einen neuen.",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Link kopiert.",
|
||||
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.",
|
||||
"openInCalendar": "In Kalender-App öffnen",
|
||||
"notFetched": "Noch nicht abgerufen.",
|
||||
"lastFetched": "Zuletzt {when} von {fetcher} abgerufen.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Kalender",
|
||||
"APPLE": "Apple Kalender",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "einer Kalender-App"
|
||||
},
|
||||
"horizon": "Zyklen voraus",
|
||||
"history": "Vergangene Zyklen",
|
||||
"historyNone": "Keine",
|
||||
"titles": "Termintitel",
|
||||
"titleMode": {
|
||||
"DISCREET": "Diskret (●)",
|
||||
"DESCRIPTIVE": "Beschreibend"
|
||||
},
|
||||
"fertile": "Fruchtbares Fenster",
|
||||
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
|
||||
"rotate": "Link erneuern",
|
||||
"rotateTitle": "Link erneuern?",
|
||||
"rotateBody": "Kalender mit dem alten Link werden nicht mehr aktualisiert. Du bekommst einen neuen Link, den du stattdessen einträgst.",
|
||||
"turnOff": "Ausschalten",
|
||||
"turnOffTitle": "Kalender-Abo ausschalten?",
|
||||
"turnOffBody": "Der Link funktioniert nicht mehr. Kopien bei deinem Kalenderanbieter bleiben, bis du den Kalender dort entfernst.",
|
||||
"help": {
|
||||
"title": "So trägst du es ein",
|
||||
"google": "Google Kalender: Weitere Kalender, Per URL, dann den Link einfügen. Google aktualisiert etwa alle 12 bis 24 Stunden.",
|
||||
"apple": "Apple Kalender: am Mac Ablage, Neues Kalenderabonnement; am iPhone Einstellungen, Kalender, Accounts, Account hinzufügen, Andere, Kalenderabo hinzufügen.",
|
||||
"outlook": "Outlook: Kalender hinzufügen, Aus dem Internet abonnieren, dann den Link einfügen. Outlook aktualisiert alle paar Stunden."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Zyklus (HealthLog)",
|
||||
"periodEstimate": "Periode (Schätzung)",
|
||||
"periodLogged": "Periode (erfasst)",
|
||||
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
|
||||
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
|
||||
"bandOne": "±{count} Tag",
|
||||
"bandFew": "±{count} Tage",
|
||||
"bandOther": "±{count} Tage"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Spätere Zyklen",
|
||||
"around": "Um den {date}",
|
||||
"state": {
|
||||
"LEARNING": "Bis 3 Zyklen erfasst sind, ist nur die nächste Periode enthalten.",
|
||||
"LATE": "Keine Prognose, solange diese Periode später kommt als erwartet; sie ist zurück, sobald du sie erfasst.",
|
||||
"OFF": "Prognosen sind ausgeschaltet; enthalten sind nur vergangene Zyklen, falls gewählt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "z. B. Lipide",
|
||||
"panelHint": "Wähle einen Vorschlag oder gib ein eigenes ein. Panels aus dem Katalog werden in jeder Sprache erkannt.",
|
||||
"lowerBound": "Referenz unten",
|
||||
"upperBound": "Referenz oben",
|
||||
"lowerPlaceholder": "Untere Grenze",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "Datei ist zu groß (max. 12 MB).",
|
||||
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
|
||||
"extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
|
||||
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
|
||||
"pdfRendererUnavailable": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen. Einen gescannten Befund kann hier nur ein KI-Anbieter lesen, der PDF-Dateien direkt annimmt. Lade stattdessen ein Foto hoch.",
|
||||
"refTextLabel": "Bereich wie gedruckt",
|
||||
"refTextPlaceholder": "z. B. 3,5 - 5,0",
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar."
|
||||
"aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar.",
|
||||
"errorUnitMismatchNoUnit": "Dieser Marker wird ohne Einheit geführt. Setz das Häkchen bei Keine Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
|
||||
"unitDiffersBadgeNoUnit": "Ohne Einheit geführt; dieser Befund nennt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Weitere Werte laden",
|
||||
"documentsLink": "Befund-Dokumente",
|
||||
"notLinkedYet": "Noch keinem Biomarker zugeordnet",
|
||||
"groups": {
|
||||
"other": "Sonstige",
|
||||
"allInRange": "Alle im Bereich",
|
||||
"outsideCountOne": "{count} von {total} außerhalb des Bereichs",
|
||||
"outsideCountFew": "{count} von {total} außerhalb des Bereichs",
|
||||
"outsideCountOther": "{count} von {total} außerhalb des Bereichs",
|
||||
"heading": "Kategorien",
|
||||
"description": "Gruppiert die Werte nach Panel, etwa Lipide oder Leber, mit einem Status je Gruppe.",
|
||||
"label": "Gruppierung",
|
||||
"none": "Keine",
|
||||
"category": "Nach Kategorie",
|
||||
"openLabel": "Beim Öffnen aufgeklappt",
|
||||
"openExpanded": "Alle",
|
||||
"openFlagged": "Nur außerhalb des Bereichs",
|
||||
"openCollapsed": "Keine",
|
||||
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Befundbereich {range}",
|
||||
"catalogDiffers": "dein Bereich {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
|
||||
"refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.",
|
||||
"aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:",
|
||||
"adoptsPanel": "Der behaltene Biomarker übernimmt das Panel {panel}.",
|
||||
"blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.",
|
||||
"noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.",
|
||||
"doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion."
|
||||
},
|
||||
"interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Keine Einheit",
|
||||
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
|
||||
"purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.",
|
||||
"duplicateExists": "Ein identisches Dokument ist bereits gespeichert.",
|
||||
"rateLimited": "Zu viele Uploads. Versuche es in ein paar Minuten erneut.",
|
||||
"uploadBusy": "Der Server war mit anderen Uploads beschäftigt. Versuche es jetzt erneut.",
|
||||
"rateLimited": "Das Upload-Limit für diese Stunde ist erreicht. Versuche es später erneut.",
|
||||
"generic": "Upload fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "Die gespeicherte Datei konnte zum Lesen nicht geöffnet werden.",
|
||||
"raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.",
|
||||
"provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.",
|
||||
"pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.",
|
||||
"pdf-needs-anthropic": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen, und dieses PDF hat keine Textebene. Lesen kann es ein KI-Anbieter, der PDF-Dateien direkt annimmt, oder du lädst Fotos der Seiten hoch.",
|
||||
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert.",
|
||||
"resource-limit": "Dieses PDF ist zu groß oder zu komplex, um es hier zu lesen. Die Datei selbst ist in Ordnung und lässt sich weiterhin öffnen."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
|
||||
"errorRateLimitedWaitOther": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
|
||||
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
|
||||
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
|
||||
"errorPdfRendererUnavailable": "Der Prozessor dieses Servers kann keine PDF-Seiten darstellen. Ein gescanntes PDF kann hier nur ein KI-Anbieter lesen, der PDF-Dateien direkt annimmt, oder du lädst Fotos der Seiten hoch.",
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
|
||||
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht",
|
||||
"cycleFeedCreate": "{name} hat das Kalender-Abo eingeschaltet",
|
||||
"cycleFeedUpdate": "{name} hat das Kalender-Abo geändert",
|
||||
"cycleFeedRotate": "{name} hat den Link des Kalender-Abos erneuert",
|
||||
"cycleFeedRevoke": "{name} hat das Kalender-Abo ausgeschaltet"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Seit deinem letzten Termin ({specialty}) am {date}",
|
||||
"kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}",
|
||||
"default": "Die letzten {days} Tage",
|
||||
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art."
|
||||
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
|
||||
"capped": "Die letzten zwei Jahre",
|
||||
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Laborwert, wie gemessen",
|
||||
"legendLabOutside": "Außerhalb des Referenzbereichs",
|
||||
"legendLabRange": "Referenzbereich"
|
||||
"legendLabRange": "Referenzbereich",
|
||||
"legendSymptom": "Symptomlinie: höchste Stärke jedes erfassten Tages (0 bis 10), gemittelt über die erfassten Tage",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptome",
|
||||
"full": "Bis zu {max} Symptome gleichzeitig. Entferne eines, um ein anderes hinzuzufügen.",
|
||||
"hidden": "ausgeblendet",
|
||||
"unnamed": "Symptom",
|
||||
"daysOne": "an 1 Tag",
|
||||
"daysFew": "Schnitt aus {count} Tagen",
|
||||
"daysOther": "Schnitt aus {count} Tagen"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Lebensereignis",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "Bei Bedarf",
|
||||
"startsOn": "Beginn",
|
||||
"endsOn": "Ende"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Ein Quotient oder Index ohne Einheit (INR, Cholesterin/HDL): Trag in der Spalte für die Einheit eine 1 ein. Eine leere Einheit bleibt ein Fehler."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Heart-rate variability",
|
||||
"typeRestingHeartRate": "Resting heart rate",
|
||||
"typeActiveEnergyBurned": "Active energy",
|
||||
"typeBasalEnergyBurned": "Resting energy",
|
||||
"typeFlightsClimbed": "Flights climbed",
|
||||
"typeWalkingRunningDistance": "Walking + running distance",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
|
||||
"durationTooLong": "One entry can't be longer than 24 hours.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stress level",
|
||||
"typeTrainingReadiness": "Training readiness",
|
||||
"typeAcuteTrainingLoad": "Acute training load",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryInjectionSite": "Injection site: {site}",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "load"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Sleep quality",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
|
||||
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
|
||||
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively.",
|
||||
"bodyBatteryExplainer": "Your energy reserve on Garmin's 0 to 100 scale, the day's highest charge. Rest and sleep fill it, stress and activity drain it.",
|
||||
"trainingReadinessExplainer": "How ready your body is for a hard session, on Garmin's 0 to 100 scale. It arrives from a bridge you set up, since Garmin does not share it directly.",
|
||||
"stressLevelExplainer": "Garmin's all-day average stress level on a 0 to 100 scale, read from your heart rate variability. Lower is calmer.",
|
||||
"acuteTrainingLoadExplainer": "Garmin's acute training load: the weighted load of your last seven days of activity. It arrives from a bridge you set up. Compare it with your own recent weeks."
|
||||
},
|
||||
"loadError": "Your recovery readings could not be loaded."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "No score was recorded in this range yet.",
|
||||
"usualRange": "Shaded: your usual range, {lo} to {hi}.",
|
||||
"seam": "The line breaks where what counts toward the score changed."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "By time of day",
|
||||
"description": "Your average blood pressure in each part of the day, over the chart's range.",
|
||||
"part": {
|
||||
"morning": "Morning",
|
||||
"midday": "Midday",
|
||||
"evening": "Evening"
|
||||
},
|
||||
"filterAll": "All day",
|
||||
"filterLabel": "Show in the chart",
|
||||
"readingsOne": "{count} reading",
|
||||
"readingsFew": "{count} readings",
|
||||
"readingsOther": "{count} readings",
|
||||
"notEnough": "Too few readings",
|
||||
"pulse": "Pulse {value} bpm",
|
||||
"difference": "Morning minus evening: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Morning and evening are compared once each has at least {min} readings.",
|
||||
"hours": "Morning {morning}, midday {midday}, evening {evening}, in your time zone.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "No complete readings in this range",
|
||||
"emptyDescription": "Readings with both a systolic and a diastolic value show up here.",
|
||||
"error": "The time-of-day averages could not be loaded.",
|
||||
"chartTitle": {
|
||||
"morning": "Blood pressure in the morning",
|
||||
"midday": "Blood pressure at midday",
|
||||
"evening": "Blood pressure in the evening"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "morning",
|
||||
"midday": "midday",
|
||||
"evening": "evening"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Synced by the iOS app, or imported from an export.",
|
||||
"file": "Imported under Settings once you are done here."
|
||||
"file": "Imported under Settings once you are done here.",
|
||||
"fitbit": "Connected through Google Health now."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Not connected yet.",
|
||||
"openSettings": "Open Settings → Integrations",
|
||||
"noDate": "unknown",
|
||||
"throughGoogleHealth": "{source} data now comes through {target}: the classic {source} connection has been shut down. Connect {target} with the Google account your {source} is signed in to, and your readings arrive from there.",
|
||||
"needsCredentials": {
|
||||
"title": "This instance has no {target} app on file yet.",
|
||||
"detail": "Enter your own credentials under Settings → Integrations, then the connection can start."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Sleep debt",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stress level",
|
||||
"trainingReadiness": "Training readiness",
|
||||
"acuteTrainingLoad": "Acute training load"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "External device",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} data types, newest {relative}",
|
||||
"quiet": "{count} quiet",
|
||||
"workouts": "Workouts",
|
||||
"medicationDoses": "Medication doses"
|
||||
"medicationDoses": "Medication doses",
|
||||
"cycleDays": "Cycle days",
|
||||
"cycleDaysCount": "Cycle days ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configured on this account without a settings card of its own."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Last entered here {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores (Body Battery, Training Load, HRV Status, VO₂ max) are not shared by Garmin to either platform.",
|
||||
"docsLink": "Read the guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glucose reference band",
|
||||
"description": "Which band your blood-glucose readings are judged against.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "You are signed out and your account is locked right away. After {days} days it is erased for good, with all health data, passkeys, sessions and audit entries. Until then, an admin of this server can undo the deletion. Copies in this server's backups age out after their retention period.",
|
||||
"deleteAccountScheduled": "Your account is locked and will be erased on {date}. Signing you out.",
|
||||
"dangerZoneSuccessGrace": "Your health record has been deleted. An admin of this server can restore it until {date}.",
|
||||
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again."
|
||||
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Paste a Garmin Personal API key to sync sleep, HRV, Body Battery, stress, steps, weight and activities from Garmin Connect.",
|
||||
"garminSync": "Sync now",
|
||||
"garminSyncResult": "{count} entries synchronized",
|
||||
"garminSyncFailed": "Sync failed",
|
||||
"garminViewData": "View your recovery",
|
||||
"garminReplaceKey": "Replace key",
|
||||
"garminDisconnect": "Disconnect",
|
||||
"garminDisconnectTitle": "Disconnect Garmin?",
|
||||
"garminDisconnectDescription": "Future syncs stop and the key is removed from this server. Data already imported stays in your history. The key keeps working at Garmin until you revoke it there.",
|
||||
"garminKeyValidUntil": "Key valid until {date}",
|
||||
"garminKeyExpired": "Your Garmin key expired on {date}. Create a new key in Garmin Connect and paste it here to resume syncing.",
|
||||
"garminKeyExpiresToday": "Your Garmin key expires today. Create a new key in Garmin Connect and paste it here so syncing does not stop.",
|
||||
"garminKeyExpiring": "Your Garmin key expires on {date}. Create a new key in Garmin Connect and paste it here before then.",
|
||||
"garminKeyExpiryUnknown": "Garmin does not say when this key expires. Replace the key and choose the validity you picked in Garmin Connect, and this card will warn you a week ahead.",
|
||||
"garminConnected": "Garmin connected. The first sync is on its way.",
|
||||
"garminKeyHowTo": "In Garmin Connect, open Settings, then Personal API, and create a key. Garmin shows it only once, so copy it straight away.",
|
||||
"garminKeySettingsLink": "Open Garmin Connect",
|
||||
"garminApiKey": "Personal API key",
|
||||
"garminApiKeyPlaceholder": "Paste the key",
|
||||
"garminApiKeySavedPlaceholder": "Saved. Paste a new key to replace it",
|
||||
"garminKeyValidity": "Validity you picked",
|
||||
"garminKeyValidityUnknown": "I don't know",
|
||||
"garminKeyValidityDays": "{count} days",
|
||||
"garminKeyValidityHelp": "Garmin does not report when a key expires. With the validity, this card warns you a week before the key stops working.",
|
||||
"garminConnect": "Connect",
|
||||
"garminUpdate": "Save changes",
|
||||
"garminRegion": "Garmin service",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin did not accept this key. Check that it was copied whole, belongs to the chosen service and has not expired or been revoked.",
|
||||
"rateLimited": "Garmin is limiting requests right now. Try again in a few minutes.",
|
||||
"unreachable": "Garmin cannot be reached. Try again in a few minutes.",
|
||||
"invalid": "That key does not look right. Paste it again, without spaces.",
|
||||
"generic": "That did not work. Try again."
|
||||
},
|
||||
"garminKeyValidityMenu": "Key validity",
|
||||
"garminValiditySaved": "Validity saved.",
|
||||
"garminKeyCreatedOn": "Created on",
|
||||
"garminKeyCreatedOnHelp": "The day you created the key in Garmin Connect. Leave it empty to count from the day you connected it here.",
|
||||
"garminUnavailable": "Garmin does not provide this data for your account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "daily summaries",
|
||||
"activities": "activities",
|
||||
"sleep": "sleep",
|
||||
"hrv": "HRV",
|
||||
"weight": "weigh-ins",
|
||||
"cycle": "menstrual cycle"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "My period started this day",
|
||||
"endedPeriodOnDate": "My period ended this day",
|
||||
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
|
||||
"startRestored": "Saved. The start on {dates} is back."
|
||||
"startRestored": "Saved. The start on {dates} is back.",
|
||||
"feed": {
|
||||
"title": "Calendar subscription",
|
||||
"description": "Your next periods as all-day events in the calendar app you already use.",
|
||||
"disclosure": "Your calendar provider (Google, iCloud, Outlook) stores these dates on its servers. Turning this off later does not remove copies it already holds.",
|
||||
"turnOn": "Turn on subscription",
|
||||
"error": "The calendar subscription could not be changed. Try again.",
|
||||
"linkHidden": "A link starting with {prefix} is active. It is shown only once, when it is made; rotate it to get a new one.",
|
||||
"linkLabel": "Subscription link",
|
||||
"linkOnce": "Shown only now. Paste it into your calendar app, or rotate it later for a new one.",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied.",
|
||||
"copyFailed": "Copying did not work. Select the link and copy it by hand.",
|
||||
"openInCalendar": "Open in calendar app",
|
||||
"notFetched": "Not fetched yet.",
|
||||
"lastFetched": "Last fetched {when} by {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Apple Calendar",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "a calendar app"
|
||||
},
|
||||
"horizon": "Cycles ahead",
|
||||
"history": "Past cycles",
|
||||
"historyNone": "None",
|
||||
"titles": "Event titles",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreet (●)",
|
||||
"DESCRIPTIVE": "Descriptive"
|
||||
},
|
||||
"fertile": "Fertile window",
|
||||
"fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.",
|
||||
"rotate": "Rotate link",
|
||||
"rotateTitle": "Rotate the link?",
|
||||
"rotateBody": "Calendars using the old link stop updating. You get a new link to add instead.",
|
||||
"turnOff": "Turn off",
|
||||
"turnOffTitle": "Turn off the calendar subscription?",
|
||||
"turnOffBody": "The link stops working. Copies your calendar provider already holds stay until you remove the calendar there.",
|
||||
"help": {
|
||||
"title": "Adding it to your calendar",
|
||||
"google": "Google Calendar: Other calendars, From URL, then paste the link. Google refreshes about every 12 to 24 hours.",
|
||||
"apple": "Apple Calendar: on a Mac, File, New Calendar Subscription; on iPhone, Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.",
|
||||
"outlook": "Outlook: Add calendar, Subscribe from web, then paste the link. Outlook refreshes every few hours."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cycle (HealthLog)",
|
||||
"periodEstimate": "Period (estimate)",
|
||||
"periodLogged": "Period (logged)",
|
||||
"fertileEstimate": "Fertile window (estimate)",
|
||||
"likelyStart": "Most likely start {date}, {band}",
|
||||
"bandOne": "±{count} day",
|
||||
"bandFew": "±{count} days",
|
||||
"bandOther": "±{count} days"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Later cycles",
|
||||
"around": "Around {date}",
|
||||
"state": {
|
||||
"LEARNING": "Until 3 cycles are logged, only the next period is included.",
|
||||
"LATE": "No forecast while this period is later than expected; it returns once you log it.",
|
||||
"OFF": "Forecasts are turned off; only past cycles are included, if chosen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Period likely soon",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "e.g. Lipids",
|
||||
"panelHint": "Pick a suggestion or type your own. Catalog panels are recognised in every language.",
|
||||
"lowerBound": "Reference low",
|
||||
"upperBound": "Reference high",
|
||||
"lowerPlaceholder": "Lower bound",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "File is too large (max 12 MB).",
|
||||
"fileType": "Upload a JPEG, PNG, WebP, or PDF.",
|
||||
"extractFailed": "Couldn't read the report. Try a clearer photo.",
|
||||
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
|
||||
"pdfRendererUnavailable": "This server's processor can't run the PDF page renderer, so a scanned report can only be read by an AI provider that takes PDF files directly. Upload a photo instead.",
|
||||
"refTextLabel": "Range as printed",
|
||||
"refTextPlaceholder": "e.g. 3.5 - 5.0",
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now."
|
||||
"aiUnavailable": "Scanning a report with AI is not available here right now.",
|
||||
"errorUnitMismatchNoUnit": "This marker is tracked without a unit. Tick No unit if the report was misread, or deselect this reading.",
|
||||
"unitDiffersBadgeNoUnit": "Tracked without a unit; this report says {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Load more readings",
|
||||
"documentsLink": "Lab report documents",
|
||||
"notLinkedYet": "Not linked to a biomarker yet",
|
||||
"groups": {
|
||||
"other": "Other",
|
||||
"allInRange": "All in range",
|
||||
"outsideCountOne": "{count} of {total} outside range",
|
||||
"outsideCountFew": "{count} of {total} outside range",
|
||||
"outsideCountOther": "{count} of {total} outside range",
|
||||
"heading": "Categories",
|
||||
"description": "Group the values by panel, such as Lipids or Liver, with a range status per group.",
|
||||
"label": "Grouping",
|
||||
"none": "None",
|
||||
"category": "By category",
|
||||
"openLabel": "Open on load",
|
||||
"openExpanded": "All",
|
||||
"openFlagged": "Outside range only",
|
||||
"openCollapsed": "None",
|
||||
"orderWithinGroups": "The order applies within each category."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Report range {range}",
|
||||
"catalogDiffers": "your range {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} readings, converted from {unit} to {targetUnit}",
|
||||
"refused": "{unit} cannot be converted to {targetUnit}.",
|
||||
"aliasesAdded": "These names will lead to the kept biomarker:",
|
||||
"adoptsPanel": "The kept biomarker takes over the panel {panel}.",
|
||||
"blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.",
|
||||
"noUndo": "A merge cannot be undone.",
|
||||
"doneToastOne": "Merged into {name}, {count} reading moved",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection."
|
||||
},
|
||||
"interval": "95 % interval of the yearly change: {lo} to {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "No unit",
|
||||
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "This file type is not supported.",
|
||||
"purged": "This document was permanently removed and cannot be restored.",
|
||||
"duplicateExists": "An identical document is already stored.",
|
||||
"rateLimited": "Too many uploads right now. Try again in a few minutes.",
|
||||
"uploadBusy": "The server was busy with other uploads. Try again now.",
|
||||
"rateLimited": "You have reached the upload limit for this hour. Try again later.",
|
||||
"generic": "Upload failed. Please try again."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "The stored file couldn't be opened for reading.",
|
||||
"raster-failed": "The PDF pages couldn't be rendered for reading. Scanned fax compression formats are a known cause.",
|
||||
"provider-error": "The AI provider couldn't read this document on the last try.",
|
||||
"pdf-needs-anthropic": "The configured AI provider can't read PDF files. A Claude provider or local OCR can.",
|
||||
"pdf-needs-anthropic": "This server's processor can't run the PDF page renderer, and this PDF has no text layer. An AI provider that takes PDF files directly can read it, or photos of the pages.",
|
||||
"empty-transcription": "The AI provider returned no text for this document.",
|
||||
"resource-limit": "This PDF is too large or complex to read here. The file itself is fine and still opens."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
|
||||
"errorRateLimitedWaitOther": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
|
||||
"errorBudget": "Your AI budget for today is used up.",
|
||||
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
|
||||
"errorPdfRendererUnavailable": "This server's processor can't run the PDF page renderer, so a scanned PDF can only be read by an AI provider that takes PDF files directly, or as photos of the pages.",
|
||||
"errorFileType": "This file type can't be read.",
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorOutputLimit": "The AI provider reached its output limit before it returned a single value. Try a model that allows longer answers.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} merged biomarkers",
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
|
||||
"auditCCreate": "{name} answered the AUDIT-C questions",
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry"
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry",
|
||||
"cycleFeedCreate": "{name} turned on the calendar subscription",
|
||||
"cycleFeedUpdate": "{name} changed the calendar subscription",
|
||||
"cycleFeedRotate": "{name} replaced the calendar subscription link",
|
||||
"cycleFeedRevoke": "{name} turned off the calendar subscription"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Since your last {specialty} visit on {date}",
|
||||
"kind": "Since your last visit of the same kind ({kind}) on {date}",
|
||||
"default": "The last {days} days",
|
||||
"defaultReason": "No earlier visit with the same practice, specialty or kind."
|
||||
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
|
||||
"capped": "The last two years",
|
||||
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Lab value, as measured",
|
||||
"legendLabOutside": "Outside the reference range",
|
||||
"legendLabRange": "Reference range"
|
||||
"legendLabRange": "Reference range",
|
||||
"legendSymptom": "Symptom line: each logged day's highest intensity (0 to 10), averaged over the logged days",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptoms",
|
||||
"full": "Up to {max} symptoms at once. Remove one to add another.",
|
||||
"hidden": "hidden",
|
||||
"unnamed": "Symptom",
|
||||
"daysOne": "on 1 day",
|
||||
"daysFew": "mean of {count} days",
|
||||
"daysOther": "mean of {count} days"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Life event",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "As needed",
|
||||
"startsOn": "Start",
|
||||
"endsOn": "End"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "A ratio or an index without a unit (INR, cholesterol/HDL): write 1 in the unit column. An empty unit cell stays an error."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Variabilidad de la frecuencia cardiaca",
|
||||
"typeRestingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"typeActiveEnergyBurned": "Energía activa",
|
||||
"typeBasalEnergyBurned": "Energía en reposo",
|
||||
"typeFlightsClimbed": "Escaleras",
|
||||
"typeWalkingRunningDistance": "Distancia a pie y corriendo",
|
||||
"typeVo2Max": "VO₂ máx",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una entrada no puede durar más de 24 horas.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Nivel de estrés",
|
||||
"typeTrainingReadiness": "Preparación para entrenar",
|
||||
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Programada",
|
||||
"intakeHistoryColStatus": "Estado",
|
||||
"intakeHistoryColSource": "Fuente",
|
||||
"intakeHistoryInjectionSite": "Lugar de inyección: {site}",
|
||||
"intakeHistoryStatusTaken": "Tomada",
|
||||
"intakeHistoryStatusSkipped": "Omitida",
|
||||
"intakeHistorySortByTaken": "Ordenar por hora de toma",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "puntos",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ppm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Calidad del sueño",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
|
||||
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
|
||||
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa.",
|
||||
"bodyBatteryExplainer": "Tu reserva de energía en la escala de 0 a 100 de Garmin, la carga más alta del día. El descanso y el sueño la llenan, el estrés y la actividad la vacían.",
|
||||
"trainingReadinessExplainer": "Lo preparado que está tu cuerpo para una sesión dura, en la escala de 0 a 100 de Garmin. Llega a través de un puente que configuraste, porque Garmin no la comparte directamente.",
|
||||
"stressLevelExplainer": "El nivel de estrés medio del día según Garmin, en una escala de 0 a 100, a partir de la variabilidad de tu frecuencia cardíaca. Más bajo es más tranquilo.",
|
||||
"acuteTrainingLoadExplainer": "La carga de entrenamiento aguda de Garmin: la carga ponderada de tu actividad de los últimos siete días. Llega a través de un puente que configuraste. Compárala con tus propias semanas recientes."
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus valores de recuperación."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "Todavía no hay ninguna puntuación registrada en este periodo.",
|
||||
"usualRange": "Sombreado: tu rango habitual, de {lo} a {hi}.",
|
||||
"seam": "La línea se interrumpe donde cambió lo que cuenta para la puntuación."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Por momento del día",
|
||||
"description": "Tu tensión arterial media en cada momento del día, en el periodo del gráfico.",
|
||||
"part": {
|
||||
"morning": "Mañana",
|
||||
"midday": "Mediodía",
|
||||
"evening": "Noche"
|
||||
},
|
||||
"filterAll": "Todo el día",
|
||||
"filterLabel": "Mostrar en el gráfico",
|
||||
"readingsOne": "{count} medición",
|
||||
"readingsFew": "{count} mediciones",
|
||||
"readingsOther": "{count} mediciones",
|
||||
"notEnough": "Muy pocas mediciones",
|
||||
"pulse": "Pulso {value} lpm",
|
||||
"difference": "Mañana menos noche: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "La mañana y la noche se comparan cuando cada una tiene al menos {min} mediciones.",
|
||||
"hours": "Mañana {morning}, mediodía {midday}, noche {evening}, en tu zona horaria.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "No hay mediciones completas en este periodo",
|
||||
"emptyDescription": "Aquí aparecen las mediciones con valor sistólico y diastólico.",
|
||||
"error": "No se pudieron cargar los promedios por momento del día.",
|
||||
"chartTitle": {
|
||||
"morning": "Tensión arterial por la mañana",
|
||||
"midday": "Tensión arterial a mediodía",
|
||||
"evening": "Tensión arterial por la noche"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "por la mañana",
|
||||
"midday": "al mediodía",
|
||||
"evening": "por la noche"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Sincronizado por la app de iOS o importado desde una exportación.",
|
||||
"file": "Se importa en Ajustes cuando termines aquí."
|
||||
"file": "Se importa en Ajustes cuando termines aquí.",
|
||||
"fitbit": "Ahora se conecta a través de Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Aún no conectado.",
|
||||
"openSettings": "Abrir Ajustes → Integraciones",
|
||||
"noDate": "desconocido",
|
||||
"throughGoogleHealth": "Los datos de {source} llegan ahora a través de {target}: la conexión clásica de {source} se ha cerrado. Conecta {target} con la cuenta de Google en la que tienes iniciada la sesión de {source} y tus lecturas llegarán desde ahí.",
|
||||
"needsCredentials": {
|
||||
"title": "Esta instancia aún no tiene una aplicación de {target} registrada.",
|
||||
"detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
"sleepDebt": "Deuda de sueño",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Nivel de estrés",
|
||||
"trainingReadiness": "Preparación para entrenar",
|
||||
"acuteTrainingLoad": "Carga de entrenamiento aguda"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Reloj",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} tipos de datos, más reciente {relative}",
|
||||
"quiet": "{count} en silencio",
|
||||
"workouts": "Entrenamientos",
|
||||
"medicationDoses": "Tomas de medicación"
|
||||
"medicationDoses": "Tomas de medicación",
|
||||
"cycleDays": "Días del ciclo",
|
||||
"cycleDaysCount": "Días del ciclo ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Último registro aquí {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin (Body Battery, Training Load, estado de HRV, VO₂ máx.) no las comparte Garmin con ninguna de las dos plataformas.",
|
||||
"docsLink": "Leer la guía"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda de referencia de glucosa",
|
||||
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Se cierra tu sesión y tu cuenta se bloquea de inmediato. Al cabo de {days} días se borra para siempre, con todos tus datos de salud, llaves de acceso, sesiones y entradas de auditoría. Hasta entonces, un administrador de este servidor puede deshacer la eliminación. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
|
||||
"deleteAccountScheduled": "Tu cuenta está bloqueada y se borrará el {date}. Cerrando tu sesión.",
|
||||
"dangerZoneSuccessGrace": "Tu historial de salud se ha eliminado. Un administrador de este servidor puede restaurarlo hasta el {date}.",
|
||||
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo."
|
||||
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Pega una clave de la Garmin Personal API para sincronizar sueño, VFC, Body Battery, estrés, pasos, peso y actividades desde Garmin Connect.",
|
||||
"garminSync": "Sincronizar ahora",
|
||||
"garminSyncResult": "{count} entradas sincronizadas",
|
||||
"garminSyncFailed": "Error de sincronización",
|
||||
"garminViewData": "Ver tu recuperación",
|
||||
"garminReplaceKey": "Reemplazar la clave",
|
||||
"garminDisconnect": "Desconectar",
|
||||
"garminDisconnectTitle": "¿Desconectar Garmin?",
|
||||
"garminDisconnectDescription": "Las sincronizaciones futuras se detienen y la clave se elimina de este servidor. Los datos ya importados se quedan en tu historial. La clave sigue funcionando en Garmin hasta que la revoques allí.",
|
||||
"garminKeyValidUntil": "Clave válida hasta el {date}",
|
||||
"garminKeyExpired": "Tu clave de Garmin caducó el {date}. Crea una clave nueva en Garmin Connect y pégala aquí para reanudar la sincronización.",
|
||||
"garminKeyExpiresToday": "Tu clave de Garmin caduca hoy. Crea una clave nueva en Garmin Connect y pégala aquí para que la sincronización no se detenga.",
|
||||
"garminKeyExpiring": "Tu clave de Garmin caduca el {date}. Crea una clave nueva en Garmin Connect y pégala aquí antes de esa fecha.",
|
||||
"garminKeyExpiryUnknown": "Garmin no indica cuándo caduca esta clave. Reemplaza la clave y elige la validez que seleccionaste en Garmin Connect, y esta tarjeta te avisará con una semana de antelación.",
|
||||
"garminConnected": "Garmin conectado. La primera sincronización está en marcha.",
|
||||
"garminKeyHowTo": "En Garmin Connect, abre Configuración, luego Personal API, y crea una clave. Garmin solo la muestra una vez, así que cópiala enseguida.",
|
||||
"garminKeySettingsLink": "Abrir Garmin Connect",
|
||||
"garminApiKey": "Clave de la Personal API",
|
||||
"garminApiKeyPlaceholder": "Pega la clave",
|
||||
"garminApiKeySavedPlaceholder": "Guardada. Pega una clave nueva para reemplazarla",
|
||||
"garminKeyValidity": "Validez elegida",
|
||||
"garminKeyValidityUnknown": "No lo sé",
|
||||
"garminKeyValidityDays": "{count} días",
|
||||
"garminKeyValidityHelp": "Garmin no informa de cuándo caduca una clave. Con la validez, esta tarjeta te avisa una semana antes de que la clave deje de funcionar.",
|
||||
"garminConnect": "Conectar",
|
||||
"garminUpdate": "Guardar cambios",
|
||||
"garminRegion": "Servicio de Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin no aceptó esta clave. Comprueba que se copió entera, que pertenece al servicio elegido y que no ha caducado ni se ha revocado.",
|
||||
"rateLimited": "Garmin está limitando las solicitudes. Inténtalo de nuevo en unos minutos.",
|
||||
"unreachable": "No se puede contactar con Garmin. Inténtalo de nuevo en unos minutos.",
|
||||
"invalid": "Esa clave no parece correcta. Pégala de nuevo, sin espacios.",
|
||||
"generic": "No ha funcionado. Inténtalo de nuevo."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validez de la clave",
|
||||
"garminValiditySaved": "Validez guardada.",
|
||||
"garminKeyCreatedOn": "Creada el",
|
||||
"garminKeyCreatedOnHelp": "El día en que creaste la clave en Garmin Connect. Si lo dejas vacío, se cuenta desde el día en que la conectaste aquí.",
|
||||
"garminUnavailable": "Garmin no ofrece estos datos para tu cuenta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "resúmenes diarios",
|
||||
"activities": "actividades",
|
||||
"sleep": "sueño",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesajes",
|
||||
"cycle": "ciclo menstrual"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mi periodo empezó este día",
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día",
|
||||
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
|
||||
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
|
||||
"startRestored": "Guardado. El inicio del {dates} vuelve a estar.",
|
||||
"feed": {
|
||||
"title": "Suscripción de calendario",
|
||||
"description": "Tus próximos periodos como eventos de todo el día en la app de calendario que ya usas.",
|
||||
"disclosure": "Tu proveedor de calendario (Google, iCloud, Outlook) guarda estas fechas en sus servidores. Desactivarlo más tarde no borra las copias que ya tiene.",
|
||||
"turnOn": "Activar suscripción",
|
||||
"error": "No se pudo cambiar la suscripción de calendario. Inténtalo de nuevo.",
|
||||
"linkHidden": "Hay activo un enlace que empieza por {prefix}. Solo se muestra una vez, al crearlo; renuévalo para obtener uno nuevo.",
|
||||
"linkLabel": "Enlace de suscripción",
|
||||
"linkOnce": "Solo se muestra ahora. Pégalo en tu app de calendario o renuévalo más tarde para obtener uno nuevo.",
|
||||
"copy": "Copiar enlace",
|
||||
"copied": "Enlace copiado.",
|
||||
"copyFailed": "No se pudo copiar. Selecciona el enlace y cópialo a mano.",
|
||||
"openInCalendar": "Abrir en la app de calendario",
|
||||
"notFetched": "Aún no se ha consultado.",
|
||||
"lastFetched": "Última consulta {when} por {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Calendario de Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "una app de calendario"
|
||||
},
|
||||
"horizon": "Ciclos por delante",
|
||||
"history": "Ciclos pasados",
|
||||
"historyNone": "Ninguno",
|
||||
"titles": "Títulos de los eventos",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descriptivo"
|
||||
},
|
||||
"fertile": "Ventana fértil",
|
||||
"fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.",
|
||||
"rotate": "Renovar enlace",
|
||||
"rotateTitle": "¿Renovar el enlace?",
|
||||
"rotateBody": "Los calendarios que usan el enlace antiguo dejan de actualizarse. Recibes un enlace nuevo para añadirlo en su lugar.",
|
||||
"turnOff": "Desactivar",
|
||||
"turnOffTitle": "¿Desactivar la suscripción de calendario?",
|
||||
"turnOffBody": "El enlace deja de funcionar. Las copias que ya tiene tu proveedor de calendario se quedan hasta que elimines allí el calendario.",
|
||||
"help": {
|
||||
"title": "Añadirlo a tu calendario",
|
||||
"google": "Google Calendar: Otros calendarios, Desde URL y pega el enlace. Google actualiza más o menos cada 12 a 24 horas.",
|
||||
"apple": "Calendario de Apple: en Mac, Archivo, Nueva suscripción a calendario; en iPhone, Ajustes, Calendario, Cuentas, Añadir cuenta, Otra, Añadir calendario suscrito.",
|
||||
"outlook": "Outlook: Agregar calendario, Suscribirse desde la web y pega el enlace. Outlook actualiza cada pocas horas."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Periodo (estimación)",
|
||||
"periodLogged": "Periodo (registrado)",
|
||||
"fertileEstimate": "Ventana fértil (estimación)",
|
||||
"likelyStart": "Inicio más probable {date}, {band}",
|
||||
"bandOne": "±{count} día",
|
||||
"bandFew": "±{count} días",
|
||||
"bandOther": "±{count} días"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Ciclos siguientes",
|
||||
"around": "Hacia el {date}",
|
||||
"state": {
|
||||
"LEARNING": "Hasta que haya 3 ciclos registrados, solo se incluye el próximo periodo.",
|
||||
"LATE": "Sin previsión mientras este periodo llegue más tarde de lo esperado; vuelve en cuanto lo registres.",
|
||||
"OFF": "Las previsiones están desactivadas; solo se incluyen ciclos pasados, si los elegiste."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "El periodo llegará pronto",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "p. ej. Lípidos",
|
||||
"panelHint": "Elige una sugerencia o escribe la tuya. Los paneles del catálogo se reconocen en todos los idiomas.",
|
||||
"lowerBound": "Referencia mínima",
|
||||
"upperBound": "Referencia máxima",
|
||||
"lowerPlaceholder": "Límite inferior",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
|
||||
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
|
||||
"extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
|
||||
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
|
||||
"pdfRendererUnavailable": "El procesador de este servidor no puede renderizar páginas PDF, así que un informe escaneado solo lo puede leer un proveedor de IA que acepte archivos PDF directamente. Sube una foto en su lugar.",
|
||||
"refTextLabel": "Rango tal como aparece",
|
||||
"refTextPlaceholder": "p. ej. 3,5 - 5,0",
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo."
|
||||
"aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador se registra sin unidad. Marca Sin unidad si el informe se leyó mal o deselecciona este valor.",
|
||||
"unitDiffersBadgeNoUnit": "Registrado sin unidad; este informe indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Cargar más mediciones",
|
||||
"documentsLink": "Documentos de resultados",
|
||||
"notLinkedYet": "Aún no vinculado a un biomarcador",
|
||||
"groups": {
|
||||
"other": "Otros",
|
||||
"allInRange": "Todo en rango",
|
||||
"outsideCountOne": "{count} de {total} fuera de rango",
|
||||
"outsideCountFew": "{count} de {total} fuera de rango",
|
||||
"outsideCountOther": "{count} de {total} fuera de rango",
|
||||
"heading": "Categorías",
|
||||
"description": "Agrupa los valores por panel, como Lípidos o Hígado, con un estado por grupo.",
|
||||
"label": "Agrupación",
|
||||
"none": "Ninguna",
|
||||
"category": "Por categoría",
|
||||
"openLabel": "Abiertas al cargar",
|
||||
"openExpanded": "Todas",
|
||||
"openFlagged": "Solo fuera de rango",
|
||||
"openCollapsed": "Ninguna",
|
||||
"orderWithinGroups": "El orden se aplica dentro de cada categoría."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Rango del informe {range}",
|
||||
"catalogDiffers": "tu rango {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}",
|
||||
"refused": "{unit} no se puede convertir a {targetUnit}.",
|
||||
"aliasesAdded": "Estos nombres llevarán al biomarcador conservado:",
|
||||
"adoptsPanel": "El biomarcador conservado adopta el panel {panel}.",
|
||||
"blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.",
|
||||
"noUndo": "Una combinación no se puede deshacer.",
|
||||
"doneToastOne": "Combinado en {name}, {count} medición movida",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección."
|
||||
},
|
||||
"interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sin unidad",
|
||||
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Este tipo de archivo no es compatible.",
|
||||
"purged": "Este documento se eliminó definitivamente y no se puede restaurar.",
|
||||
"duplicateExists": "Ya hay guardado un documento idéntico.",
|
||||
"rateLimited": "Demasiadas subidas. Inténtalo de nuevo en unos minutos.",
|
||||
"uploadBusy": "El servidor estaba ocupado con otras subidas. Inténtalo de nuevo ahora.",
|
||||
"rateLimited": "Has alcanzado el límite de subidas de esta hora. Inténtalo de nuevo más tarde.",
|
||||
"generic": "La subida falló. Inténtalo de nuevo."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "El archivo guardado no se pudo abrir para su lectura.",
|
||||
"raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura. Los formatos de compresión de fax escaneados son una causa conocida.",
|
||||
"provider-error": "El proveedor de IA no pudo leer este documento en el último intento.",
|
||||
"pdf-needs-anthropic": "El proveedor de IA configurado no puede leer archivos PDF. Un proveedor Claude o el OCR local sí pueden.",
|
||||
"pdf-needs-anthropic": "El procesador de este servidor no puede renderizar páginas PDF y este PDF no tiene capa de texto. Puede leerlo un proveedor de IA que acepte archivos PDF directamente, o sube fotos de las páginas.",
|
||||
"empty-transcription": "El proveedor de IA no devolvió texto para este documento.",
|
||||
"resource-limit": "Este PDF es demasiado grande o complejo para leerlo aquí. El archivo en sí está bien y se sigue pudiendo abrir."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
|
||||
"errorRateLimitedWaitOther": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
|
||||
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
|
||||
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
|
||||
"errorPdfRendererUnavailable": "El procesador de este servidor no puede renderizar páginas PDF, así que un PDF escaneado solo lo puede leer un proveedor de IA que acepte archivos PDF directamente, o como fotos de las páginas.",
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
"errorNotIndexed": "Primero lee el documento y luego extrae.",
|
||||
"errorOutputLimit": "El proveedor de IA alcanzó su límite de salida antes de devolver un solo valor. Prueba con un modelo que permita respuestas más largas.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} combinó biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
|
||||
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C",
|
||||
"cycleFeedCreate": "{name} activó la suscripción de calendario",
|
||||
"cycleFeedUpdate": "{name} cambió la suscripción de calendario",
|
||||
"cycleFeedRotate": "{name} renovó el enlace de la suscripción de calendario",
|
||||
"cycleFeedRevoke": "{name} desactivó la suscripción de calendario"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Desde tu última visita ({specialty}) el {date}",
|
||||
"kind": "Desde tu última visita del mismo tipo ({kind}) el {date}",
|
||||
"default": "Los últimos {days} días",
|
||||
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo."
|
||||
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
|
||||
"capped": "Los últimos dos años",
|
||||
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valor de laboratorio, tal como se midió",
|
||||
"legendLabOutside": "Fuera del intervalo de referencia",
|
||||
"legendLabRange": "Intervalo de referencia"
|
||||
"legendLabRange": "Intervalo de referencia",
|
||||
"legendSymptom": "Línea de síntoma: la intensidad más alta de cada día registrado (0 a 10), promediada sobre los días registrados",
|
||||
"symptoms": {
|
||||
"menuTitle": "Síntomas",
|
||||
"full": "Hasta {max} síntomas a la vez. Quita uno para añadir otro.",
|
||||
"hidden": "oculto",
|
||||
"unnamed": "Síntoma",
|
||||
"daysOne": "en 1 día",
|
||||
"daysFew": "media de {count} días",
|
||||
"daysOther": "media de {count} días"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Acontecimiento vital",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "A demanda",
|
||||
"startsOn": "Inicio",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un cociente o un índice sin unidad (INR, colesterol/HDL): escribe 1 en la columna de la unidad. Una unidad vacía sigue siendo un error."
|
||||
}
|
||||
}
|
||||
+260
-52
@@ -739,10 +739,10 @@
|
||||
"emptyDescription": "Saisissez votre première mesure ou humeur. Les tuiles et graphiques apparaissent automatiquement dès qu’il y a des données.",
|
||||
"emptyAddMeasurement": "Saisir la première mesure",
|
||||
"staleHint": "Dernière valeur il y a {count} j",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a 1 semaine",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a {count} semaine",
|
||||
"staleHintWeeksOther": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintWeeksFew": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a 1 mois",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsOther": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsFew": "Dernière valeur il y a {count} mois",
|
||||
"recentWorkouts": {
|
||||
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Variabilité de la fréquence cardiaque",
|
||||
"typeRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
"typeActiveEnergyBurned": "Énergie active",
|
||||
"typeBasalEnergyBurned": "Énergie au repos",
|
||||
"typeFlightsClimbed": "Étages",
|
||||
"typeWalkingRunningDistance": "Distance à pied et en course",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Saisissez une durée comme 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Niveau de stress",
|
||||
"typeTrainingReadiness": "Disponibilité à l'entraînement",
|
||||
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Programmée",
|
||||
"intakeHistoryColStatus": "Statut",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryInjectionSite": "Site d'injection : {site}",
|
||||
"intakeHistoryStatusTaken": "Prise",
|
||||
"intakeHistoryStatusSkipped": "Ignorée",
|
||||
"intakeHistorySortByTaken": "Trier par heure de prise",
|
||||
@@ -2280,7 +2287,7 @@
|
||||
"currentSince": "Plan actuel depuis {date}",
|
||||
"noTimes": "Aucune heure fixe",
|
||||
"manualChip": "Ajoutée manuellement",
|
||||
"showErasOne": "Afficher 1 période antérieure",
|
||||
"showErasOne": "Afficher {count} période antérieure",
|
||||
"showErasOther": "Afficher {count} périodes antérieures",
|
||||
"hideEras": "Masquer les périodes antérieures",
|
||||
"addEra": "Ajouter une période antérieure",
|
||||
@@ -2352,7 +2359,7 @@
|
||||
"tableColActions": "Actions",
|
||||
"tableSortBy": "Trier par {column}",
|
||||
"tableComplianceDays": "{days} j",
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoseOne": "{count} dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} jours restants",
|
||||
"cardLowStockRunway": "Réserve : ≈ {days} jours",
|
||||
@@ -3231,7 +3238,7 @@
|
||||
"settingsClustersLabel": "Données vues par le Coach",
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCountOne": "1 mesure",
|
||||
"sourcesMemberCountOne": "{count} mesure",
|
||||
"sourcesMemberCountFew": "{count} mesures",
|
||||
"sourcesMemberCountOther": "{count} mesures",
|
||||
"lookback": {
|
||||
@@ -3331,7 +3338,7 @@
|
||||
"indexing": "Indexation…",
|
||||
"indexingHint": "Un document est encore en cours d'indexation ; vous pourrez envoyer une fois prêt.",
|
||||
"fencedHint": "Cette conversation s'exécute sans accès à vos données de santé : le coach lit uniquement les documents joints.",
|
||||
"documentsAttachedOne": "1 document joint",
|
||||
"documentsAttachedOne": "{count} document joint",
|
||||
"documentsAttachedOther": "{count} documents joints",
|
||||
"railBadge": "Chat de document cloisonné",
|
||||
"uploadError": "Échec du téléversement. Veuillez réessayer.",
|
||||
@@ -3935,9 +3942,9 @@
|
||||
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
|
||||
"learningBodyEmpty": "Dès que quelques mesures arrivent (saisies par vous ou synchronisées depuis un appareil), votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
|
||||
"readingsCount": "{count} mesures",
|
||||
"readingsCountOne": "1 mesure",
|
||||
"readingsCountOne": "{count} mesure",
|
||||
"daysCount": "{days} jours",
|
||||
"daysCountOne": "1 jour",
|
||||
"daysCountOne": "{count} jour",
|
||||
"tir": {
|
||||
"title": "Temps dans la cible",
|
||||
"caption": "Part des mesures dans chaque plage ({low}–{high} {unit} est la cible).",
|
||||
@@ -3989,7 +3996,7 @@
|
||||
"bedtime": "Au coucher",
|
||||
"unspecified": "Non précisée",
|
||||
"readingsShort": "{count} mesures",
|
||||
"readingsShortOne": "1 mesure"
|
||||
"readingsShortOne": "{count} mesure"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "points",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "charge"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualité du sommeil",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
|
||||
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
|
||||
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement.",
|
||||
"bodyBatteryExplainer": "Votre réserve d'énergie sur l'échelle de 0 à 100 de Garmin, le niveau le plus haut de la journée. Le repos et le sommeil la rechargent, le stress et l'activité la vident.",
|
||||
"trainingReadinessExplainer": "À quel point votre corps est prêt pour une séance intense, sur l'échelle de 0 à 100 de Garmin. La valeur arrive par une passerelle que vous avez configurée, car Garmin ne la partage pas directement.",
|
||||
"stressLevelExplainer": "Le niveau de stress moyen de la journée selon Garmin, sur une échelle de 0 à 100, tiré de la variabilité de votre fréquence cardiaque. Plus bas signifie plus calme.",
|
||||
"acuteTrainingLoadExplainer": "La charge d'entraînement aiguë de Garmin : la charge pondérée de vos activités des sept derniers jours. Elle arrive par une passerelle que vous avez configurée. Comparez-la à vos propres semaines récentes."
|
||||
},
|
||||
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
|
||||
},
|
||||
@@ -4923,7 +4935,7 @@
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Enregistrements ECG",
|
||||
"recordingsOne": "1 enregistrement disponible.",
|
||||
"recordingsOne": "{count} enregistrement disponible.",
|
||||
"recordingsMany": "{count} enregistrements disponibles.",
|
||||
"latestResult": "Dernier résultat de l’appareil : {result}.",
|
||||
"cta": "Voir les enregistrements"
|
||||
@@ -4937,7 +4949,7 @@
|
||||
"caption": "Moyenné par tranches de 10 minutes.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée. Les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"coverageOne": "Basé sur 1 mesure sur {hours}h.",
|
||||
"coverageOne": "Basé sur {count} mesure sur {hours}h.",
|
||||
"coverageMany": "Basé sur {count} mesures sur {hours}h.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "Aucun score n’a encore été enregistré sur cette période.",
|
||||
"usualRange": "En grisé : votre plage habituelle, de {lo} à {hi}.",
|
||||
"seam": "La ligne s’interrompt là où ce qui compte pour le score a changé."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Selon le moment de la journée",
|
||||
"description": "Votre tension artérielle moyenne à chaque moment de la journée, sur la période du graphique.",
|
||||
"part": {
|
||||
"morning": "Matin",
|
||||
"midday": "Midi",
|
||||
"evening": "Soir"
|
||||
},
|
||||
"filterAll": "Toute la journée",
|
||||
"filterLabel": "Afficher dans le graphique",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "{count} mesures",
|
||||
"readingsOther": "{count} mesures",
|
||||
"notEnough": "Trop peu de mesures",
|
||||
"pulse": "Pouls {value} bpm",
|
||||
"difference": "Matin moins soir : {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Le matin et le soir sont comparés dès que chacun compte au moins {min} mesures.",
|
||||
"hours": "Matin {morning}, midi {midday}, soir {evening}, dans votre fuseau horaire.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "Aucune mesure complète sur cette période",
|
||||
"emptyDescription": "Les mesures avec une valeur systolique et diastolique apparaissent ici.",
|
||||
"error": "Impossible de charger les moyennes selon le moment de la journée.",
|
||||
"chartTitle": {
|
||||
"morning": "Tension artérielle le matin",
|
||||
"midday": "Tension artérielle à midi",
|
||||
"evening": "Tension artérielle le soir"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "le matin",
|
||||
"midday": "à midi",
|
||||
"evening": "le soir"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Synchronisé par l'app iOS, ou importé depuis un export.",
|
||||
"file": "À importer dans les réglages une fois cette étape terminée."
|
||||
"file": "À importer dans les réglages une fois cette étape terminée.",
|
||||
"fitbit": "Se connecte désormais via Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Pas encore connecté.",
|
||||
"openSettings": "Ouvrir Paramètres → Intégrations",
|
||||
"noDate": "inconnue",
|
||||
"throughGoogleHealth": "Les données {source} passent désormais par {target} : l'ancienne connexion {source} a été arrêtée. Connectez {target} avec le compte Google auquel votre {source} est associé, et vos mesures arriveront par là.",
|
||||
"needsCredentials": {
|
||||
"title": "Cette instance n’a pas encore d’application {target} enregistrée.",
|
||||
"detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer."
|
||||
@@ -5393,7 +5442,7 @@
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Première tendance après 5 mesures",
|
||||
"remainingOne": "Encore 1 mesure avant la tendance.",
|
||||
"remainingOne": "Encore {count} mesure avant la tendance.",
|
||||
"remainingMany": "Encore {count} mesures avant la tendance."
|
||||
},
|
||||
"settings": {
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Dette de sommeil",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Niveau de stress",
|
||||
"trainingReadiness": "Disponibilité à l'entraînement",
|
||||
"acuteTrainingLoad": "Charge d'entraînement aiguë"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Appareil externe",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Montre",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} types de données, plus récent {relative}",
|
||||
"quiet": "{count} silencieux",
|
||||
"workouts": "Séances",
|
||||
"medicationDoses": "Prises de médicaments"
|
||||
"medicationDoses": "Prises de médicaments",
|
||||
"cycleDays": "Jours du cycle",
|
||||
"cycleDaysCount": "Jours du cycle ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configuré sur ce compte sans carte de réglages dédiée."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Dernière saisie ici {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin n'a pas de connexion directe ici. Connectez-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin (Body Battery, Training Load, statut VFC, VO₂ max) ne sont partagés par Garmin avec aucune des deux plateformes.",
|
||||
"docsLink": "Lire le guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Plage de référence du glucose",
|
||||
"description": "La plage à laquelle vos glycémies sont comparées.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Vous êtes déconnecté et votre compte est verrouillé immédiatement. Au bout de {days} jours, il est effacé définitivement, avec toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit. D’ici là, un administrateur de ce serveur peut annuler la suppression. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
|
||||
"deleteAccountScheduled": "Votre compte est verrouillé et sera effacé le {date}. Déconnexion en cours.",
|
||||
"dangerZoneSuccessGrace": "Votre dossier de santé a été supprimé. Un administrateur de ce serveur peut le restaurer jusqu’au {date}.",
|
||||
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau."
|
||||
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Collez une clé Garmin Personal API pour synchroniser le sommeil, la VFC, la Body Battery, le stress, les pas, le poids et les activités depuis Garmin Connect.",
|
||||
"garminSync": "Synchroniser maintenant",
|
||||
"garminSyncResult": "{count} entrées synchronisées",
|
||||
"garminSyncFailed": "Échec de la synchronisation",
|
||||
"garminViewData": "Voir votre récupération",
|
||||
"garminReplaceKey": "Remplacer la clé",
|
||||
"garminDisconnect": "Déconnecter",
|
||||
"garminDisconnectTitle": "Déconnecter Garmin ?",
|
||||
"garminDisconnectDescription": "Les synchronisations futures s'arrêtent et la clé est supprimée de ce serveur. Les données déjà importées restent dans votre historique. La clé reste valide chez Garmin jusqu'à ce que vous la révoquiez.",
|
||||
"garminKeyValidUntil": "Clé valide jusqu'au {date}",
|
||||
"garminKeyExpired": "Votre clé Garmin a expiré le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour reprendre la synchronisation.",
|
||||
"garminKeyExpiresToday": "Votre clé Garmin expire aujourd'hui. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour que la synchronisation continue.",
|
||||
"garminKeyExpiring": "Votre clé Garmin expire le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici avant cette date.",
|
||||
"garminKeyExpiryUnknown": "Garmin n'indique pas quand cette clé expire. Remplacez la clé et choisissez la validité sélectionnée dans Garmin Connect : cette carte vous préviendra une semaine avant.",
|
||||
"garminConnected": "Garmin connecté. La première synchronisation est en route.",
|
||||
"garminKeyHowTo": "Dans Garmin Connect, ouvrez Paramètres, puis Personal API, et créez une clé. Garmin ne l'affiche qu'une fois : copiez-la tout de suite.",
|
||||
"garminKeySettingsLink": "Ouvrir Garmin Connect",
|
||||
"garminApiKey": "Clé Personal API",
|
||||
"garminApiKeyPlaceholder": "Collez la clé",
|
||||
"garminApiKeySavedPlaceholder": "Enregistrée. Collez une nouvelle clé pour la remplacer",
|
||||
"garminKeyValidity": "Validité choisie",
|
||||
"garminKeyValidityUnknown": "Je ne sais pas",
|
||||
"garminKeyValidityDays": "{count} jours",
|
||||
"garminKeyValidityHelp": "Garmin n'indique pas quand une clé expire. Avec la validité, cette carte vous prévient une semaine avant que la clé cesse de fonctionner.",
|
||||
"garminConnect": "Connecter",
|
||||
"garminUpdate": "Enregistrer les modifications",
|
||||
"garminRegion": "Service Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (international)",
|
||||
"garminRegionChina": "Garmin Connect Chine",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin n'a pas accepté cette clé. Vérifiez qu'elle a été copiée en entier, qu'elle appartient au service choisi et qu'elle n'a ni expiré ni été révoquée.",
|
||||
"rateLimited": "Garmin limite les requêtes en ce moment. Réessayez dans quelques minutes.",
|
||||
"unreachable": "Impossible de joindre Garmin. Réessayez dans quelques minutes.",
|
||||
"invalid": "Cette clé ne semble pas correcte. Collez-la à nouveau, sans espaces.",
|
||||
"generic": "Cela n'a pas fonctionné. Réessayez."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validité de la clé",
|
||||
"garminValiditySaved": "Validité enregistrée.",
|
||||
"garminKeyCreatedOn": "Créée le",
|
||||
"garminKeyCreatedOnHelp": "Le jour où vous avez créé la clé dans Garmin Connect. Laissé vide, le décompte part du jour où vous l'avez connectée ici.",
|
||||
"garminUnavailable": "Garmin ne fournit pas ces données pour votre compte : {list}.",
|
||||
"garminResource": {
|
||||
"daily": "résumés quotidiens",
|
||||
"activities": "activités",
|
||||
"sleep": "sommeil",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesées",
|
||||
"cycle": "cycle menstruel"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -7913,7 +8015,7 @@
|
||||
"colRedeemedBy": "Utilisée par",
|
||||
"colActions": "Actions",
|
||||
"byCreator": "par {by}",
|
||||
"expiresInDaysOne": "dans 1 jour",
|
||||
"expiresInDaysOne": "dans {count} jour",
|
||||
"expiresInDaysOther": "dans {count} jours",
|
||||
"statusActive": "Active",
|
||||
"statusExpired": "Expirée",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
|
||||
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
|
||||
"startRestored": "Enregistré. Le début du {dates} est de retour."
|
||||
"startRestored": "Enregistré. Le début du {dates} est de retour.",
|
||||
"feed": {
|
||||
"title": "Abonnement au calendrier",
|
||||
"description": "Vos prochaines règles en événements sur la journée, dans l'application de calendrier que vous utilisez déjà.",
|
||||
"disclosure": "Votre fournisseur de calendrier (Google, iCloud, Outlook) conserve ces dates sur ses serveurs. Désactiver l'abonnement plus tard ne supprime pas les copies qu'il détient déjà.",
|
||||
"turnOn": "Activer l'abonnement",
|
||||
"error": "L'abonnement au calendrier n'a pas pu être modifié. Réessayez.",
|
||||
"linkHidden": "Un lien commençant par {prefix} est actif. Il n'est affiché qu'une fois, à sa création ; renouvelez-le pour en obtenir un nouveau.",
|
||||
"linkLabel": "Lien d'abonnement",
|
||||
"linkOnce": "Affiché uniquement maintenant. Collez-le dans votre application de calendrier, ou renouvelez-le plus tard pour en obtenir un nouveau.",
|
||||
"copy": "Copier le lien",
|
||||
"copied": "Lien copié.",
|
||||
"copyFailed": "La copie n'a pas fonctionné. Sélectionnez le lien et copiez-le manuellement.",
|
||||
"openInCalendar": "Ouvrir dans l'application de calendrier",
|
||||
"notFetched": "Pas encore récupéré.",
|
||||
"lastFetched": "Dernière récupération {when} par {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Calendrier Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "une application de calendrier"
|
||||
},
|
||||
"horizon": "Cycles à venir",
|
||||
"history": "Cycles passés",
|
||||
"historyNone": "Aucun",
|
||||
"titles": "Titres des événements",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discret (●)",
|
||||
"DESCRIPTIVE": "Explicite"
|
||||
},
|
||||
"fertile": "Fenêtre fertile",
|
||||
"fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.",
|
||||
"rotate": "Renouveler le lien",
|
||||
"rotateTitle": "Renouveler le lien ?",
|
||||
"rotateBody": "Les calendriers qui utilisent l'ancien lien ne sont plus mis à jour. Vous recevez un nouveau lien à ajouter à la place.",
|
||||
"turnOff": "Désactiver",
|
||||
"turnOffTitle": "Désactiver l'abonnement au calendrier ?",
|
||||
"turnOffBody": "Le lien ne fonctionne plus. Les copies que détient déjà votre fournisseur de calendrier restent jusqu'à ce que vous y supprimiez le calendrier.",
|
||||
"help": {
|
||||
"title": "L'ajouter à votre calendrier",
|
||||
"google": "Google Agenda : Autres agendas, À partir de l'URL, puis collez le lien. Google actualise environ toutes les 12 à 24 heures.",
|
||||
"apple": "Calendrier Apple : sur Mac, Fichier, Nouvel abonnement à un calendrier ; sur iPhone, Réglages, Calendrier, Comptes, Ajouter un compte, Autre, Ajouter un calendrier avec abonnement.",
|
||||
"outlook": "Outlook : Ajouter un calendrier, S'abonner à partir du web, puis collez le lien. Outlook actualise toutes les quelques heures."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cycle (HealthLog)",
|
||||
"periodEstimate": "Règles (estimation)",
|
||||
"periodLogged": "Règles (enregistrées)",
|
||||
"fertileEstimate": "Fenêtre fertile (estimation)",
|
||||
"likelyStart": "Début le plus probable {date}, {band}",
|
||||
"bandOne": "±{count} jour",
|
||||
"bandFew": "±{count} jours",
|
||||
"bandOther": "±{count} jours"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Cycles suivants",
|
||||
"around": "Vers le {date}",
|
||||
"state": {
|
||||
"LEARNING": "Tant que 3 cycles ne sont pas enregistrés, seules les prochaines règles sont incluses.",
|
||||
"LATE": "Pas de prévision tant que ces règles sont plus tardives que prévu ; elle revient dès que vous les enregistrez.",
|
||||
"OFF": "Les prévisions sont désactivées ; seuls les cycles passés sont inclus, si vous les avez choisis."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Règles probablement bientôt",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "ex. Lipides",
|
||||
"panelHint": "Choisissez une suggestion ou saisissez la vôtre. Les panels du catalogue sont reconnus dans toutes les langues.",
|
||||
"lowerBound": "Référence basse",
|
||||
"upperBound": "Référence haute",
|
||||
"lowerPlaceholder": "Borne inférieure",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
|
||||
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
|
||||
"extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
|
||||
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
|
||||
"pdfRendererUnavailable": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF : un compte rendu scanné ne peut être lu ici que par un fournisseur d’IA qui accepte directement les fichiers PDF. Importez plutôt une photo.",
|
||||
"refTextLabel": "Plage telle qu'imprimée",
|
||||
"refTextPlaceholder": "p. ex. 3,5 - 5,0",
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment."
|
||||
"aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment.",
|
||||
"errorUnitMismatchNoUnit": "Ce marqueur est suivi sans unité. Cochez Sans unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
|
||||
"unitDiffersBadgeNoUnit": "Suivi sans unité ; ce compte rendu indique {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Charger plus de mesures",
|
||||
"documentsLink": "Documents de résultats",
|
||||
"notLinkedYet": "Pas encore lié à un biomarqueur",
|
||||
"groups": {
|
||||
"other": "Autres",
|
||||
"allInRange": "Tout dans la norme",
|
||||
"outsideCountOne": "{count} sur {total} hors norme",
|
||||
"outsideCountFew": "{count} sur {total} hors norme",
|
||||
"outsideCountOther": "{count} sur {total} hors norme",
|
||||
"heading": "Catégories",
|
||||
"description": "Regroupe les valeurs par panel, par exemple Lipides ou Foie, avec un statut par groupe.",
|
||||
"label": "Regroupement",
|
||||
"none": "Aucun",
|
||||
"category": "Par catégorie",
|
||||
"openLabel": "Ouvertes à l’affichage",
|
||||
"openExpanded": "Toutes",
|
||||
"openFlagged": "Hors norme seulement",
|
||||
"openCollapsed": "Aucune",
|
||||
"orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
"catalogDiffers": "votre plage {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} mesures, converties de {unit} en {targetUnit}",
|
||||
"refused": "{unit} ne peut pas être converti en {targetUnit}.",
|
||||
"aliasesAdded": "Ces noms mèneront au biomarqueur conservé :",
|
||||
"adoptsPanel": "Le biomarqueur conservé reprend le panel {panel}.",
|
||||
"blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laissez ce biomarqueur de côté ou conservez-en un autre.",
|
||||
"noUndo": "Une fusion ne peut pas être annulée.",
|
||||
"doneToastOne": "Fusionné dans {name}, {count} mesure déplacée",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection."
|
||||
},
|
||||
"interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sans unité",
|
||||
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10845,7 +11035,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"title": "Votre historique de maladies",
|
||||
"countOne": "Vous avez enregistré 1 épisode sur cette période.",
|
||||
"countOne": "Vous avez enregistré {count} épisode sur cette période.",
|
||||
"countOther": "Vous avez enregistré {count} épisodes sur cette période.",
|
||||
"typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de vos valeurs.",
|
||||
"typicalGapWithDriver": "Écart de récupération typique : {days} jours. Vous vous sentez généralement mieux avant que {vital} ne se stabilise.",
|
||||
@@ -10858,7 +11048,7 @@
|
||||
"resolved": "Résolus"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 poussée",
|
||||
"countOne": "{count} poussée",
|
||||
"countOther": "{count} poussées"
|
||||
},
|
||||
"timeline": {
|
||||
@@ -10990,13 +11180,13 @@
|
||||
"toggleShow": "Afficher ce que le Coach a consulté",
|
||||
"toggleHide": "Masquer ce que le Coach a consulté",
|
||||
"announceDone": "Terminé : {label}",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "{count} mesures",
|
||||
"readingsOther": "{count} mesures",
|
||||
"rowsOne": "1 ligne",
|
||||
"rowsOne": "{count} ligne",
|
||||
"rowsFew": "{count} lignes",
|
||||
"rowsOther": "{count} lignes",
|
||||
"metricsOne": "1 indicateur",
|
||||
"metricsOne": "{count} indicateur",
|
||||
"metricsFew": "{count} indicateurs",
|
||||
"metricsOther": "{count} indicateurs",
|
||||
"reason": {
|
||||
@@ -11100,7 +11290,7 @@
|
||||
"entry": "{domain}, {window} : {detail}",
|
||||
"entryNoWindow": "{domain} : {detail}",
|
||||
"reused": "d'une réponse précédente",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "{count} mesures",
|
||||
"readingsOther": "{count} mesures",
|
||||
"aggregation": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Ce type de fichier n'est pas pris en charge.",
|
||||
"purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.",
|
||||
"duplicateExists": "Un document identique est déjà enregistré.",
|
||||
"rateLimited": "Trop de téléversements. Réessayez dans quelques minutes.",
|
||||
"uploadBusy": "Le serveur était occupé par d’autres téléversements. Réessayez maintenant.",
|
||||
"rateLimited": "Vous avez atteint la limite de téléversements pour cette heure. Réessayez plus tard.",
|
||||
"generic": "Le téléversement a échoué. Veuillez réessayer."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "Le fichier stocké n’a pas pu être ouvert pour la lecture.",
|
||||
"raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture. Les formats de compression de fax numérisés en sont une cause connue.",
|
||||
"provider-error": "Le fournisseur d’IA n’a pas pu lire ce document lors de la dernière tentative.",
|
||||
"pdf-needs-anthropic": "Le fournisseur d’IA configuré ne peut pas lire les fichiers PDF. Un fournisseur Claude ou l’OCR local le peut.",
|
||||
"pdf-needs-anthropic": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF, et ce PDF n’a pas de couche texte. Un fournisseur d’IA qui accepte directement les fichiers PDF peut le lire, ou des photos des pages.",
|
||||
"empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document.",
|
||||
"resource-limit": "Ce PDF est trop volumineux ou trop complexe pour être lu ici. Le fichier lui-même est intact et s'ouvre toujours."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
|
||||
"errorRateLimitedWaitOther": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
|
||||
"errorBudget": "Votre budget IA du jour est épuisé.",
|
||||
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
|
||||
"errorPdfRendererUnavailable": "Le processeur de ce serveur ne peut pas faire le rendu des pages PDF : un PDF scanné ne peut être lu ici que par un fournisseur d’IA qui accepte directement les fichiers PDF, ou sous forme de photos des pages.",
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
|
||||
"errorOutputLimit": "Le fournisseur d’IA a atteint sa limite de sortie avant de renvoyer la moindre valeur. Essayez un modèle qui accepte des réponses plus longues.",
|
||||
@@ -12057,7 +12248,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "L’IA a lu votre document.",
|
||||
"readStagedOne": "L’IA a lu votre document. 1 valeur de laboratoire attend votre vérification.",
|
||||
"readStagedOne": "L’IA a lu votre document. {count} valeur de laboratoire attend votre vérification.",
|
||||
"readStagedFew": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"readStagedOther": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"read": "Lire avec l’IA",
|
||||
@@ -12089,12 +12280,12 @@
|
||||
"fieldValueText": "Résultat (texte)",
|
||||
"fieldUnit": "Unité",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valeur reste à vérifier et demeure en attente.",
|
||||
"unverifiedNoteOne": "{count} valeur reste à vérifier et demeure en attente.",
|
||||
"unverifiedNoteFew": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"finish": "Terminer la vérification",
|
||||
"confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.",
|
||||
"savedToastOne": "1 valeur enregistrée",
|
||||
"savedToastOne": "{count} valeur enregistrée",
|
||||
"savedToastFew": "{count} valeurs enregistrées",
|
||||
"savedToastOther": "{count} valeurs enregistrées",
|
||||
"nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
|
||||
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C",
|
||||
"cycleFeedCreate": "{name} a activé l'abonnement au calendrier",
|
||||
"cycleFeedUpdate": "{name} a modifié l'abonnement au calendrier",
|
||||
"cycleFeedRotate": "{name} a renouvelé le lien de l'abonnement au calendrier",
|
||||
"cycleFeedRevoke": "{name} a désactivé l'abonnement au calendrier"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Depuis votre dernière visite ({specialty}) le {date}",
|
||||
"kind": "Depuis votre dernière visite du même type ({kind}) le {date}",
|
||||
"default": "Les {days} derniers jours",
|
||||
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type."
|
||||
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
|
||||
"capped": "Les deux dernières années",
|
||||
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13408,7 +13605,7 @@
|
||||
"deleteBody": "« {symptom} » et toutes ses entrées seront supprimés. Pour garder l'historique, masquez-le plutôt.",
|
||||
"deleteEventTitle": "Supprimer l'entrée ?",
|
||||
"deleteEventBody": "Cette entrée sera supprimée.",
|
||||
"hiddenOne": "Masqués (1)",
|
||||
"hiddenOne": "Masqués ({count})",
|
||||
"hiddenFew": "Masqués ({count})",
|
||||
"hiddenOther": "Masqués ({count})",
|
||||
"recent": "30 derniers jours",
|
||||
@@ -13475,14 +13672,14 @@
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
"valuesFewer": "Moins de valeurs",
|
||||
"countEntriesOne": "1 entrée",
|
||||
"countEntriesOne": "{count} entrée",
|
||||
"countEntriesFew": "{count} entrées",
|
||||
"countEntriesOther": "{count} entrées",
|
||||
"meanOfReadingsOne": "Moyenne de 1 mesure",
|
||||
"meanOfReadingsOne": "Moyenne de {count} mesure",
|
||||
"meanOfReadingsFew": "Moyenne de {count} mesures",
|
||||
"meanOfReadingsOther": "Moyenne de {count} mesures",
|
||||
"sinceLastVisit": {
|
||||
"countOne": "1 entrée",
|
||||
"countOne": "{count} entrée",
|
||||
"countFew": "{count} entrées",
|
||||
"countOther": "{count} entrées",
|
||||
"allOne": "Tout afficher",
|
||||
@@ -13671,7 +13868,7 @@
|
||||
},
|
||||
"noValue": "aucune valeur",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "moyenne de {count} mesures",
|
||||
"readingsOther": "moyenne de {count} mesures"
|
||||
},
|
||||
@@ -13704,7 +13901,7 @@
|
||||
"empty": "Aucune entrée de {from} à {to}",
|
||||
"emptySingle": "Aucune entrée en {period}",
|
||||
"ended": "Fin : {label}",
|
||||
"afterDaysOne": "après 1 jour",
|
||||
"afterDaysOne": "après {count} jour",
|
||||
"afterDaysFew": "après {count} jours",
|
||||
"afterDaysOther": "après {count} jours",
|
||||
"notable": {
|
||||
@@ -13728,7 +13925,7 @@
|
||||
"apply": "Appliquer"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "1 valeur de laboratoire",
|
||||
"labValuesOne": "{count} valeur de laboratoire",
|
||||
"labValuesFew": "{count} valeurs de laboratoire",
|
||||
"labValuesOther": "{count} valeurs de laboratoire"
|
||||
},
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valeur de laboratoire, telle que mesurée",
|
||||
"legendLabOutside": "Hors de l’intervalle de référence",
|
||||
"legendLabRange": "Intervalle de référence"
|
||||
"legendLabRange": "Intervalle de référence",
|
||||
"legendSymptom": "Ligne de symptôme : intensité la plus forte de chaque jour noté (0 à 10), en moyenne sur les jours notés",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptômes",
|
||||
"full": "Jusqu’à {max} symptômes à la fois. Retirez-en un pour en ajouter un autre.",
|
||||
"hidden": "masqué",
|
||||
"unnamed": "Symptôme",
|
||||
"daysOne": "sur {count} jour",
|
||||
"daysFew": "moyenne sur {count} jours",
|
||||
"daysOther": "moyenne sur {count} jours"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Événement de vie",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "Si besoin",
|
||||
"startsOn": "Début",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapport ou un indice sans unité (INR, cholestérol/HDL) : inscrivez 1 dans la colonne de l'unité. Une unité vide reste une erreur."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Variabilità della frequenza cardiaca",
|
||||
"typeRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"typeActiveEnergyBurned": "Energia attiva",
|
||||
"typeBasalEnergyBurned": "Energia a riposo",
|
||||
"typeFlightsClimbed": "Piani saliti",
|
||||
"typeWalkingRunningDistance": "Distanza a piedi e di corsa",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una voce non può superare le 24 ore.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Livello di stress",
|
||||
"typeTrainingReadiness": "Prontezza all'allenamento",
|
||||
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Programmata",
|
||||
"intakeHistoryColStatus": "Stato",
|
||||
"intakeHistoryColSource": "Sorgente",
|
||||
"intakeHistoryInjectionSite": "Sito di iniezione: {site}",
|
||||
"intakeHistoryStatusTaken": "Assunta",
|
||||
"intakeHistoryStatusSkipped": "Saltata",
|
||||
"intakeHistorySortByTaken": "Ordina per ora di assunzione",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "punti",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carico"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualità del sonno",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
|
||||
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
|
||||
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo.",
|
||||
"bodyBatteryExplainer": "La tua riserva di energia sulla scala da 0 a 100 di Garmin, il livello più alto della giornata. Riposo e sonno la ricaricano, stress e attività la consumano.",
|
||||
"trainingReadinessExplainer": "Quanto il tuo corpo è pronto per una sessione impegnativa, sulla scala da 0 a 100 di Garmin. Arriva tramite un ponte che hai configurato, perché Garmin non la condivide direttamente.",
|
||||
"stressLevelExplainer": "Il livello di stress medio della giornata secondo Garmin, su una scala da 0 a 100, ricavato dalla variabilità della frequenza cardiaca. Più basso significa più calmo.",
|
||||
"acuteTrainingLoadExplainer": "Il carico di allenamento acuto di Garmin: il carico ponderato delle tue attività degli ultimi sette giorni. Arriva tramite un ponte che hai configurato. Confrontalo con le tue settimane recenti."
|
||||
},
|
||||
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "In questo periodo non è ancora stato registrato alcun punteggio.",
|
||||
"usualRange": "In ombra: il tuo intervallo abituale, da {lo} a {hi}.",
|
||||
"seam": "La linea si interrompe dove è cambiato ciò che conta per il punteggio."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Per momento della giornata",
|
||||
"description": "La tua pressione arteriosa media in ogni momento della giornata, nel periodo del grafico.",
|
||||
"part": {
|
||||
"morning": "Mattina",
|
||||
"midday": "Mezzogiorno",
|
||||
"evening": "Sera"
|
||||
},
|
||||
"filterAll": "Tutto il giorno",
|
||||
"filterLabel": "Mostra nel grafico",
|
||||
"readingsOne": "{count} misurazione",
|
||||
"readingsFew": "{count} misurazioni",
|
||||
"readingsOther": "{count} misurazioni",
|
||||
"notEnough": "Troppe poche misurazioni",
|
||||
"pulse": "Pulsazioni {value} bpm",
|
||||
"difference": "Mattina meno sera: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Mattina e sera vengono confrontate quando ciascuna ha almeno {min} misurazioni.",
|
||||
"hours": "Mattina {morning}, mezzogiorno {midday}, sera {evening}, nel tuo fuso orario.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "Nessuna misurazione completa in questo periodo",
|
||||
"emptyDescription": "Qui compaiono le misurazioni con valore sistolico e diastolico.",
|
||||
"error": "Impossibile caricare le medie per momento della giornata.",
|
||||
"chartTitle": {
|
||||
"morning": "Pressione arteriosa al mattino",
|
||||
"midday": "Pressione arteriosa a mezzogiorno",
|
||||
"evening": "Pressione arteriosa alla sera"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "al mattino",
|
||||
"midday": "a mezzogiorno",
|
||||
"evening": "alla sera"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Sincronizzato dall'app iOS, oppure importato da un'esportazione.",
|
||||
"file": "Si importa nelle Impostazioni quando hai finito qui."
|
||||
"file": "Si importa nelle Impostazioni quando hai finito qui.",
|
||||
"fitbit": "Ora si collega tramite Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Non ancora collegato.",
|
||||
"openSettings": "Apri Impostazioni → Integrazioni",
|
||||
"noDate": "sconosciuto",
|
||||
"throughGoogleHealth": "I dati {source} ora arrivano tramite {target}: la connessione classica a {source} è stata chiusa. Collega {target} con l'account Google a cui è associato il tuo {source} e le tue misurazioni arriveranno da lì.",
|
||||
"needsCredentials": {
|
||||
"title": "Questa istanza non ha ancora un’app {target} registrata.",
|
||||
"detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Debito di sonno",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Livello di stress",
|
||||
"trainingReadiness": "Prontezza all'allenamento",
|
||||
"acuteTrainingLoad": "Carico di allenamento acuto"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo esterno",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Orologio",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} tipi di dati, più recente {relative}",
|
||||
"quiet": "{count} in silenzio",
|
||||
"workouts": "Allenamenti",
|
||||
"medicationDoses": "Assunzioni di farmaci"
|
||||
"medicationDoses": "Assunzioni di farmaci",
|
||||
"cycleDays": "Giorni del ciclo",
|
||||
"cycleDaysCount": "Giorni del ciclo ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Ultimo inserimento qui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin (Body Battery, Training Load, stato HRV, VO₂ max) non vengono condivisi da Garmin con nessuna delle due piattaforme.",
|
||||
"docsLink": "Leggi la guida"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda di riferimento della glicemia",
|
||||
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Vieni disconnesso e il tuo account viene bloccato subito. Dopo {days} giorni viene cancellato definitivamente, con tutti i dati sanitari, le passkey, le sessioni e le voci di audit. Fino ad allora un amministratore di questo server può annullare l’eliminazione. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
|
||||
"deleteAccountScheduled": "Il tuo account è bloccato e verrà cancellato il {date}. Disconnessione in corso.",
|
||||
"dangerZoneSuccessGrace": "La tua cartella sanitaria è stata eliminata. Un amministratore di questo server può ripristinarla fino al {date}.",
|
||||
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo."
|
||||
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Incolla una chiave Garmin Personal API per sincronizzare sonno, HRV, Body Battery, stress, passi, peso e attività da Garmin Connect.",
|
||||
"garminSync": "Sincronizza ora",
|
||||
"garminSyncResult": "{count} voci sincronizzate",
|
||||
"garminSyncFailed": "Sincronizzazione non riuscita",
|
||||
"garminViewData": "Vedi il tuo recupero",
|
||||
"garminReplaceKey": "Sostituisci la chiave",
|
||||
"garminDisconnect": "Disconnetti",
|
||||
"garminDisconnectTitle": "Disconnettere Garmin?",
|
||||
"garminDisconnectDescription": "Le sincronizzazioni future si fermano e la chiave viene rimossa da questo server. I dati già importati restano nella tua cronologia. La chiave resta valida su Garmin finché non la revochi lì.",
|
||||
"garminKeyValidUntil": "Chiave valida fino al {date}",
|
||||
"garminKeyExpired": "La tua chiave Garmin è scaduta il {date}. Crea una nuova chiave in Garmin Connect e incollala qui per riprendere la sincronizzazione.",
|
||||
"garminKeyExpiresToday": "La tua chiave Garmin scade oggi. Crea una nuova chiave in Garmin Connect e incollala qui perché la sincronizzazione non si fermi.",
|
||||
"garminKeyExpiring": "La tua chiave Garmin scade il {date}. Crea prima una nuova chiave in Garmin Connect e incollala qui.",
|
||||
"garminKeyExpiryUnknown": "Garmin non dice quando scade questa chiave. Sostituisci la chiave e scegli la validità impostata in Garmin Connect: questa scheda ti avviserà una settimana prima.",
|
||||
"garminConnected": "Garmin collegato. La prima sincronizzazione è in arrivo.",
|
||||
"garminKeyHowTo": "In Garmin Connect apri Impostazioni, poi Personal API, e crea una chiave. Garmin la mostra una sola volta, quindi copiala subito.",
|
||||
"garminKeySettingsLink": "Apri Garmin Connect",
|
||||
"garminApiKey": "Chiave Personal API",
|
||||
"garminApiKeyPlaceholder": "Incolla la chiave",
|
||||
"garminApiKeySavedPlaceholder": "Salvata. Incolla una nuova chiave per sostituirla",
|
||||
"garminKeyValidity": "Validità scelta",
|
||||
"garminKeyValidityUnknown": "Non lo so",
|
||||
"garminKeyValidityDays": "{count} giorni",
|
||||
"garminKeyValidityHelp": "Garmin non segnala quando una chiave scade. Con la validità, questa scheda ti avvisa una settimana prima che la chiave smetta di funzionare.",
|
||||
"garminConnect": "Collega",
|
||||
"garminUpdate": "Salva modifiche",
|
||||
"garminRegion": "Servizio Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (globale)",
|
||||
"garminRegionChina": "Garmin Connect Cina",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin non ha accettato questa chiave. Controlla che sia stata copiata per intero, che appartenga al servizio scelto e che non sia scaduta o revocata.",
|
||||
"rateLimited": "Garmin sta limitando le richieste. Riprova tra qualche minuto.",
|
||||
"unreachable": "Garmin non è raggiungibile. Riprova tra qualche minuto.",
|
||||
"invalid": "Questa chiave non sembra corretta. Incollala di nuovo, senza spazi.",
|
||||
"generic": "Non ha funzionato. Riprova."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validità della chiave",
|
||||
"garminValiditySaved": "Validità salvata.",
|
||||
"garminKeyCreatedOn": "Creata il",
|
||||
"garminKeyCreatedOnHelp": "Il giorno in cui hai creato la chiave in Garmin Connect. Se lo lasci vuoto, si conta dal giorno in cui l'hai collegata qui.",
|
||||
"garminUnavailable": "Garmin non fornisce questi dati per il tuo account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "riepiloghi giornalieri",
|
||||
"activities": "attività",
|
||||
"sleep": "sonno",
|
||||
"hrv": "HRV",
|
||||
"weight": "pesate",
|
||||
"cycle": "ciclo mestruale"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
|
||||
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
|
||||
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
|
||||
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente.",
|
||||
"feed": {
|
||||
"title": "Abbonamento al calendario",
|
||||
"description": "Le tue prossime mestruazioni come eventi di un'intera giornata nell'app di calendario che usi già.",
|
||||
"disclosure": "Il tuo fornitore di calendario (Google, iCloud, Outlook) conserva queste date sui suoi server. Disattivare l'abbonamento in seguito non elimina le copie che ha già.",
|
||||
"turnOn": "Attiva abbonamento",
|
||||
"error": "Non è stato possibile modificare l'abbonamento al calendario. Riprova.",
|
||||
"linkHidden": "È attivo un link che inizia con {prefix}. Viene mostrato una sola volta, quando viene creato; rinnovalo per averne uno nuovo.",
|
||||
"linkLabel": "Link di abbonamento",
|
||||
"linkOnce": "Visibile solo adesso. Incollalo nella tua app di calendario, oppure rinnovalo più tardi per averne uno nuovo.",
|
||||
"copy": "Copia link",
|
||||
"copied": "Link copiato.",
|
||||
"copyFailed": "La copia non è riuscita. Seleziona il link e copialo a mano.",
|
||||
"openInCalendar": "Apri nell'app di calendario",
|
||||
"notFetched": "Non ancora scaricato.",
|
||||
"lastFetched": "Ultimo scaricamento {when} da {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Calendario di Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "un'app di calendario"
|
||||
},
|
||||
"horizon": "Cicli in avanti",
|
||||
"history": "Cicli passati",
|
||||
"historyNone": "Nessuno",
|
||||
"titles": "Titoli degli eventi",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descrittivo"
|
||||
},
|
||||
"fertile": "Finestra fertile",
|
||||
"fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.",
|
||||
"rotate": "Rinnova link",
|
||||
"rotateTitle": "Rinnovare il link?",
|
||||
"rotateBody": "I calendari che usano il vecchio link smettono di aggiornarsi. Ricevi un nuovo link da aggiungere al suo posto.",
|
||||
"turnOff": "Disattiva",
|
||||
"turnOffTitle": "Disattivare l'abbonamento al calendario?",
|
||||
"turnOffBody": "Il link smette di funzionare. Le copie che il tuo fornitore di calendario ha già restano finché non rimuovi lì il calendario.",
|
||||
"help": {
|
||||
"title": "Aggiungerlo al calendario",
|
||||
"google": "Google Calendar: Altri calendari, Da URL, poi incolla il link. Google aggiorna circa ogni 12-24 ore.",
|
||||
"apple": "Calendario di Apple: sul Mac, File, Nuovo abbonamento a calendario; su iPhone, Impostazioni, Calendario, Account, Aggiungi account, Altro, Aggiungi calendario sottoscritto.",
|
||||
"outlook": "Outlook: Aggiungi calendario, Sottoscrivi dal Web, poi incolla il link. Outlook aggiorna ogni poche ore."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Mestruazioni (stima)",
|
||||
"periodLogged": "Mestruazioni (registrate)",
|
||||
"fertileEstimate": "Finestra fertile (stima)",
|
||||
"likelyStart": "Inizio più probabile {date}, {band}",
|
||||
"bandOne": "±{count} giorno",
|
||||
"bandFew": "±{count} giorni",
|
||||
"bandOther": "±{count} giorni"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Cicli successivi",
|
||||
"around": "Intorno al {date}",
|
||||
"state": {
|
||||
"LEARNING": "Finché non sono registrati 3 cicli, è incluso solo il prossimo ciclo mestruale.",
|
||||
"LATE": "Nessuna previsione finché queste mestruazioni sono più in ritardo del previsto; torna appena le registri.",
|
||||
"OFF": "Le previsioni sono disattivate; sono inclusi solo i cicli passati, se li hai scelti."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Ciclo probabilmente in arrivo",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "es. Lipidi",
|
||||
"panelHint": "Scegli un suggerimento o scrivine uno tuo. I panel del catalogo vengono riconosciuti in ogni lingua.",
|
||||
"lowerBound": "Riferimento minimo",
|
||||
"upperBound": "Riferimento massimo",
|
||||
"lowerPlaceholder": "Limite inferiore",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "Il file è troppo grande (max 12 MB).",
|
||||
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
|
||||
"extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
|
||||
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
|
||||
"pdfRendererUnavailable": "Il processore di questo server non può eseguire il rendering delle pagine PDF, quindi un referto scansionato può essere letto solo da un provider IA che accetta direttamente i file PDF. Carica invece una foto.",
|
||||
"refTextLabel": "Intervallo come stampato",
|
||||
"refTextPlaceholder": "es. 3,5 - 5,0",
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento."
|
||||
"aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento.",
|
||||
"errorUnitMismatchNoUnit": "Questo marcatore è registrato senza unità. Spunta Nessuna unità se il referto è stato letto male, oppure deseleziona questo valore.",
|
||||
"unitDiffersBadgeNoUnit": "Registrato senza unità; questo referto indica {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carica altre misurazioni",
|
||||
"documentsLink": "Documenti dei referti",
|
||||
"notLinkedYet": "Non ancora collegato a un biomarcatore",
|
||||
"groups": {
|
||||
"other": "Altri",
|
||||
"allInRange": "Tutti nella norma",
|
||||
"outsideCountOne": "{count} su {total} fuori norma",
|
||||
"outsideCountFew": "{count} su {total} fuori norma",
|
||||
"outsideCountOther": "{count} su {total} fuori norma",
|
||||
"heading": "Categorie",
|
||||
"description": "Raggruppa i valori per panel, ad esempio Lipidi o Fegato, con uno stato per gruppo.",
|
||||
"label": "Raggruppamento",
|
||||
"none": "Nessuno",
|
||||
"category": "Per categoria",
|
||||
"openLabel": "Aperte al caricamento",
|
||||
"openExpanded": "Tutte",
|
||||
"openFlagged": "Solo fuori norma",
|
||||
"openCollapsed": "Nessuna",
|
||||
"orderWithinGroups": "L’ordine vale all’interno di ogni categoria."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
"catalogDiffers": "il tuo intervallo {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}",
|
||||
"refused": "{unit} non può essere convertito in {targetUnit}.",
|
||||
"aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:",
|
||||
"adoptsPanel": "Il biomarcatore mantenuto assume il panel {panel}.",
|
||||
"blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.",
|
||||
"noUndo": "Un'unione non può essere annullata.",
|
||||
"doneToastOne": "Unito in {name}, {count} misurazione spostata",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione."
|
||||
},
|
||||
"interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Nessuna unità",
|
||||
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Questo tipo di file non è supportato.",
|
||||
"purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.",
|
||||
"duplicateExists": "Un documento identico è già archiviato.",
|
||||
"rateLimited": "Troppi caricamenti. Riprova tra qualche minuto.",
|
||||
"uploadBusy": "Il server era occupato con altri caricamenti. Riprova adesso.",
|
||||
"rateLimited": "Hai raggiunto il limite di caricamenti per quest’ora. Riprova più tardi.",
|
||||
"generic": "Caricamento non riuscito. Riprova."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "Il file archiviato non può essere aperto per la lettura.",
|
||||
"raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura. I formati di compressione fax digitalizzati sono una causa nota.",
|
||||
"provider-error": "Il provider IA non è riuscito a leggere questo documento all’ultimo tentativo.",
|
||||
"pdf-needs-anthropic": "Il provider IA configurato non può leggere i file PDF. Un provider Claude o l’OCR locale sì.",
|
||||
"pdf-needs-anthropic": "Il processore di questo server non può eseguire il rendering delle pagine PDF e questo PDF non ha un livello di testo. Può leggerlo un provider IA che accetta direttamente i file PDF, oppure carica le foto delle pagine.",
|
||||
"empty-transcription": "Il provider IA non ha restituito testo per questo documento.",
|
||||
"resource-limit": "Questo PDF è troppo grande o complesso per essere letto qui. Il file in sé è a posto e si apre ancora."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
|
||||
"errorRateLimitedWaitOther": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
|
||||
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
|
||||
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
|
||||
"errorPdfRendererUnavailable": "Il processore di questo server non può eseguire il rendering delle pagine PDF, quindi un PDF scansionato può essere letto solo da un provider IA che accetta direttamente i file PDF, oppure come foto delle pagine.",
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
|
||||
"errorOutputLimit": "Il fornitore di IA ha raggiunto il suo limite di output prima di restituire un solo valore. Prova con un modello che consente risposte più lunghe.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} ha unito dei biomarcatori",
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
|
||||
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C",
|
||||
"cycleFeedCreate": "{name} ha attivato l'abbonamento al calendario",
|
||||
"cycleFeedUpdate": "{name} ha modificato l'abbonamento al calendario",
|
||||
"cycleFeedRotate": "{name} ha rinnovato il link dell'abbonamento al calendario",
|
||||
"cycleFeedRevoke": "{name} ha disattivato l'abbonamento al calendario"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Dalla tua ultima visita ({specialty}) del {date}",
|
||||
"kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}",
|
||||
"default": "Gli ultimi {days} giorni",
|
||||
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo."
|
||||
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
|
||||
"capped": "Gli ultimi due anni",
|
||||
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valore di laboratorio, così come misurato",
|
||||
"legendLabOutside": "Fuori dall’intervallo di riferimento",
|
||||
"legendLabRange": "Intervallo di riferimento"
|
||||
"legendLabRange": "Intervallo di riferimento",
|
||||
"legendSymptom": "Linea del sintomo: l’intensità più alta di ogni giorno registrato (da 0 a 10), in media sui giorni registrati",
|
||||
"symptoms": {
|
||||
"menuTitle": "Sintomi",
|
||||
"full": "Fino a {max} sintomi alla volta. Rimuovine uno per aggiungerne un altro.",
|
||||
"hidden": "nascosto",
|
||||
"unnamed": "Sintomo",
|
||||
"daysOne": "in 1 giorno",
|
||||
"daysFew": "media di {count} giorni",
|
||||
"daysOther": "media di {count} giorni"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento di vita",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "Al bisogno",
|
||||
"startsOn": "Inizio",
|
||||
"endsOn": "Fine"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Un rapporto o un indice senza unità (INR, colesterolo/HDL): scrivi 1 nella colonna dell'unità. Un'unità vuota resta un errore."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "심박변이도",
|
||||
"typeRestingHeartRate": "안정 시 심박수",
|
||||
"typeActiveEnergyBurned": "활동 에너지",
|
||||
"typeBasalEnergyBurned": "휴식 에너지",
|
||||
"typeFlightsClimbed": "오른 층수",
|
||||
"typeWalkingRunningDistance": "걷기 + 달리기 거리",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes}분",
|
||||
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
|
||||
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "바디 배터리",
|
||||
"typeStressLevel": "스트레스 수준",
|
||||
"typeTrainingReadiness": "트레이닝 준비도",
|
||||
"typeAcuteTrainingLoad": "급성 훈련 부하",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "HealthLog가 기록할 수 있는 것",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "예정",
|
||||
"intakeHistoryColStatus": "상태",
|
||||
"intakeHistoryColSource": "출처",
|
||||
"intakeHistoryInjectionSite": "주사 부위: {site}",
|
||||
"intakeHistoryStatusTaken": "복용함",
|
||||
"intakeHistoryStatusSkipped": "건너뜀",
|
||||
"intakeHistorySortByTaken": "복용 시각순 정렬",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "점",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "부하"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "수면 품질",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "쌓인 급성 심혈관 부하를 따라가는 훈련 부하 지표예요. 내 최근 며칠과 비교해 보세요.",
|
||||
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
|
||||
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
|
||||
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
|
||||
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄).",
|
||||
"bodyBatteryExplainer": "Garmin의 0~100 척도로 본 에너지 비축량으로, 하루 중 가장 높았던 값입니다. 휴식과 수면은 채우고, 스트레스와 활동은 소모합니다.",
|
||||
"trainingReadinessExplainer": "Garmin의 0~100 척도로 본 고강도 운동에 대한 준비 정도입니다. Garmin이 직접 공유하지 않으므로 직접 설정한 브리지를 통해 들어옵니다.",
|
||||
"stressLevelExplainer": "심박변이도에서 읽어낸 Garmin의 하루 평균 스트레스 수준(0~100)입니다. 낮을수록 차분한 상태입니다.",
|
||||
"acuteTrainingLoadExplainer": "Garmin의 급성 훈련 부하로, 최근 7일 동안의 활동 부하를 가중 합산한 값입니다. 직접 설정한 브리지를 통해 들어옵니다. 최근 몇 주의 내 기록과 비교해 보세요."
|
||||
},
|
||||
"loadError": "회복 값을 불러오지 못했어요."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.",
|
||||
"usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.",
|
||||
"seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "시간대별",
|
||||
"description": "차트 기간 동안 하루 중 시간대별 평균 혈압입니다.",
|
||||
"part": {
|
||||
"morning": "아침",
|
||||
"midday": "낮",
|
||||
"evening": "저녁"
|
||||
},
|
||||
"filterAll": "하루 전체",
|
||||
"filterLabel": "차트에 표시",
|
||||
"readingsOne": "측정 {count}회",
|
||||
"readingsFew": "측정 {count}회",
|
||||
"readingsOther": "측정 {count}회",
|
||||
"notEnough": "측정 횟수 부족",
|
||||
"pulse": "맥박 {value} bpm",
|
||||
"difference": "아침 빼기 저녁: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "아침과 저녁에 각각 측정이 {min}회 이상 있으면 비교합니다.",
|
||||
"hours": "아침 {morning}, 낮 {midday}, 저녁 {evening}, 내 시간대 기준.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "이 기간에 완전한 측정이 없습니다",
|
||||
"emptyDescription": "수축기와 이완기 값이 모두 있는 측정이 여기에 표시됩니다.",
|
||||
"error": "시간대별 평균을 불러오지 못했습니다.",
|
||||
"chartTitle": {
|
||||
"morning": "아침 혈압",
|
||||
"midday": "낮 혈압",
|
||||
"evening": "저녁 혈압"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "아침",
|
||||
"midday": "낮",
|
||||
"evening": "저녁"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.",
|
||||
"file": "여기서 마친 뒤 설정에서 가져옵니다."
|
||||
"file": "여기서 마친 뒤 설정에서 가져옵니다.",
|
||||
"fitbit": "이제 Google Health를 통해 연결됩니다."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "아직 연결되지 않았습니다.",
|
||||
"openSettings": "설정 → 연동 열기",
|
||||
"noDate": "알 수 없음",
|
||||
"throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.",
|
||||
"needsCredentials": {
|
||||
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
|
||||
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "회복",
|
||||
"stress": "스트레스",
|
||||
"sleepDebt": "수면 부채",
|
||||
"hrvRmssd": "심박변이도 (RMSSD)"
|
||||
"hrvRmssd": "심박변이도 (RMSSD)",
|
||||
"bodyBattery": "바디 배터리",
|
||||
"stressLevel": "스트레스 수준",
|
||||
"trainingReadiness": "트레이닝 준비도",
|
||||
"acuteTrainingLoad": "급성 훈련 부하"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "외부 기기",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "워치",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "데이터 유형 {count}개, 최신 {relative}",
|
||||
"quiet": "{count}개 잠잠",
|
||||
"workouts": "운동",
|
||||
"medicationDoses": "약 복용"
|
||||
"medicationDoses": "약 복용",
|
||||
"cycleDays": "주기 기록일",
|
||||
"cycleDaysCount": "주기 기록일 ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "여기서는 Garmin에 직접 연결할 수 없어요. Apple Health(iOS)나 Google Health Connect(Android)를 거쳐 연결하세요. Body Battery, Training Load, HRV Status, VO₂ max 같은 Garmin 고유 점수는 Garmin이 두 플랫폼 어디에도 공유하지 않아요.",
|
||||
"docsLink": "가이드 보기"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "혈당 참고 구간",
|
||||
"description": "혈당 측정값을 어떤 구간에 견줘 판단할지 정해요.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "바로 로그아웃되고 계정이 잠겨요. {days}일 뒤에는 모든 건강 데이터, 패스키, 세션, 감사 기록과 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
|
||||
"deleteAccountScheduled": "계정이 잠겼고 {date}에 완전히 지워져요. 로그아웃하는 중이에요.",
|
||||
"dangerZoneSuccessGrace": "건강 기록이 삭제됐어요. {date}까지는 이 서버의 관리자가 복원할 수 있어요.",
|
||||
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요."
|
||||
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Garmin Personal API 키를 붙여 넣으면 Garmin Connect의 수면, HRV, 바디 배터리, 스트레스, 걸음 수, 체중, 활동이 동기화됩니다.",
|
||||
"garminSync": "지금 동기화",
|
||||
"garminSyncResult": "{count}개 항목 동기화됨",
|
||||
"garminSyncFailed": "동기화 실패",
|
||||
"garminViewData": "회복 보기",
|
||||
"garminReplaceKey": "키 교체",
|
||||
"garminDisconnect": "연결 해제",
|
||||
"garminDisconnectTitle": "Garmin 연결을 해제할까요?",
|
||||
"garminDisconnectDescription": "앞으로의 동기화가 중지되고 키가 이 서버에서 삭제됩니다. 이미 가져온 데이터는 기록에 남습니다. 키는 Garmin에서 취소할 때까지 계속 유효합니다.",
|
||||
"garminKeyValidUntil": "키 유효 기간: {date}까지",
|
||||
"garminKeyExpired": "Garmin 키가 {date}에 만료되었습니다. Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으면 동기화가 다시 시작됩니다.",
|
||||
"garminKeyExpiresToday": "Garmin 키가 오늘 만료됩니다. 동기화가 멈추지 않도록 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
|
||||
"garminKeyExpiring": "Garmin 키가 {date}에 만료됩니다. 그 전에 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
|
||||
"garminKeyExpiryUnknown": "Garmin은 이 키의 만료일을 알려 주지 않습니다. 키를 교체하면서 Garmin Connect에서 고른 유효 기간을 선택하면 이 카드가 일주일 전에 알려 드립니다.",
|
||||
"garminConnected": "Garmin이 연결되었습니다. 첫 동기화가 시작됩니다.",
|
||||
"garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.",
|
||||
"garminKeySettingsLink": "Garmin Connect 열기",
|
||||
"garminApiKey": "Personal API 키",
|
||||
"garminApiKeyPlaceholder": "키 붙여 넣기",
|
||||
"garminApiKeySavedPlaceholder": "저장됨. 교체하려면 새 키를 붙여 넣으세요",
|
||||
"garminKeyValidity": "선택한 유효 기간",
|
||||
"garminKeyValidityUnknown": "모르겠음",
|
||||
"garminKeyValidityDays": "{count}일",
|
||||
"garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.",
|
||||
"garminConnect": "연결",
|
||||
"garminUpdate": "변경 사항 저장",
|
||||
"garminRegion": "Garmin 서비스",
|
||||
"garminRegionGlobal": "Garmin Connect (글로벌)",
|
||||
"garminRegionChina": "Garmin Connect 중국",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin이 이 키를 받아들이지 않았습니다. 키를 빠짐없이 복사했는지, 선택한 서비스의 키인지, 만료되거나 취소되지 않았는지 확인하세요.",
|
||||
"rateLimited": "Garmin이 지금 요청을 제한하고 있습니다. 몇 분 후에 다시 시도하세요.",
|
||||
"unreachable": "Garmin에 연결할 수 없습니다. 몇 분 후에 다시 시도하세요.",
|
||||
"invalid": "키 형식이 올바르지 않습니다. 공백 없이 다시 붙여 넣으세요.",
|
||||
"generic": "실패했습니다. 다시 시도하세요."
|
||||
},
|
||||
"garminKeyValidityMenu": "키 유효 기간",
|
||||
"garminValiditySaved": "유효 기간을 저장했습니다.",
|
||||
"garminKeyCreatedOn": "생성일",
|
||||
"garminKeyCreatedOnHelp": "Garmin Connect에서 키를 만든 날입니다. 비워 두면 여기에 연결한 날부터 계산합니다.",
|
||||
"garminUnavailable": "Garmin이 내 계정에 이 데이터를 제공하지 않습니다: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "일일 요약",
|
||||
"activities": "활동",
|
||||
"sleep": "수면",
|
||||
"hrv": "HRV",
|
||||
"weight": "체중 측정",
|
||||
"cycle": "생리 주기"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "관리자",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "이 날 생리를 시작했어요",
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요",
|
||||
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요.",
|
||||
"feed": {
|
||||
"title": "캘린더 구독",
|
||||
"description": "다음 생리 예정일을 지금 쓰는 캘린더 앱에 종일 일정으로 보여줘요.",
|
||||
"disclosure": "캘린더 제공업체(Google, iCloud, Outlook)는 이 날짜를 자사 서버에 저장해요. 나중에 구독을 꺼도 이미 가져간 사본은 지워지지 않아요.",
|
||||
"turnOn": "구독 켜기",
|
||||
"error": "캘린더 구독을 변경하지 못했어요. 다시 시도해 주세요.",
|
||||
"linkHidden": "{prefix}(으)로 시작하는 링크가 활성 상태예요. 링크는 만들 때 한 번만 보여요. 새 링크가 필요하면 다시 발급하세요.",
|
||||
"linkLabel": "구독 링크",
|
||||
"linkOnce": "지금만 보여요. 캘린더 앱에 붙여 넣거나, 나중에 다시 발급해 새 링크를 받으세요.",
|
||||
"copy": "링크 복사",
|
||||
"copied": "링크를 복사했어요.",
|
||||
"copyFailed": "복사하지 못했어요. 링크를 선택해 직접 복사하세요.",
|
||||
"openInCalendar": "캘린더 앱에서 열기",
|
||||
"notFetched": "아직 가져가지 않았어요.",
|
||||
"lastFetched": "{fetcher}에서 마지막으로 가져감: {when}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google 캘린더",
|
||||
"APPLE": "Apple 캘린더",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "캘린더 앱"
|
||||
},
|
||||
"horizon": "앞으로의 주기",
|
||||
"history": "지난 주기",
|
||||
"historyNone": "없음",
|
||||
"titles": "일정 제목",
|
||||
"titleMode": {
|
||||
"DISCREET": "드러나지 않게 (●)",
|
||||
"DESCRIPTIVE": "내용 표시"
|
||||
},
|
||||
"fertile": "가임기",
|
||||
"fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.",
|
||||
"rotate": "링크 다시 발급",
|
||||
"rotateTitle": "링크를 다시 발급할까요?",
|
||||
"rotateBody": "이전 링크를 쓰는 캘린더는 더 이상 업데이트되지 않아요. 대신 추가할 새 링크를 받게 돼요.",
|
||||
"turnOff": "끄기",
|
||||
"turnOffTitle": "캘린더 구독을 끌까요?",
|
||||
"turnOffBody": "링크가 더 이상 작동하지 않아요. 캘린더 제공업체가 이미 가진 사본은 거기서 캘린더를 삭제할 때까지 남아 있어요.",
|
||||
"help": {
|
||||
"title": "캘린더에 추가하는 방법",
|
||||
"google": "Google 캘린더: 다른 캘린더, URL로 추가에서 링크를 붙여 넣으세요. Google은 대략 12~24시간마다 새로 고쳐요.",
|
||||
"apple": "Apple 캘린더: Mac에서는 파일, 새로운 캘린더 구독. iPhone에서는 설정, 캘린더, 계정, 계정 추가, 기타, 구독 캘린더 추가.",
|
||||
"outlook": "Outlook: 캘린더 추가, 웹에서 구독에서 링크를 붙여 넣으세요. Outlook은 몇 시간마다 새로 고쳐요."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "주기 (HealthLog)",
|
||||
"periodEstimate": "생리 (예상)",
|
||||
"periodLogged": "생리 (기록)",
|
||||
"fertileEstimate": "가임기 (예상)",
|
||||
"likelyStart": "가장 가능성 높은 시작일 {date}, {band}",
|
||||
"bandOne": "±{count}일",
|
||||
"bandFew": "±{count}일",
|
||||
"bandOther": "±{count}일"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "이후 주기",
|
||||
"around": "{date}쯤",
|
||||
"state": {
|
||||
"LEARNING": "주기가 3번 기록될 때까지는 다음 생리만 포함돼요.",
|
||||
"LATE": "이번 생리가 예상보다 늦어지는 동안에는 예측이 없어요. 기록하면 다시 나타나요.",
|
||||
"OFF": "예측이 꺼져 있어요. 선택한 경우 지난 주기만 포함돼요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
|
||||
"panel": "패널",
|
||||
"panelPlaceholder": "예: 지질",
|
||||
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
|
||||
"lowerBound": "참고 하한",
|
||||
"upperBound": "참고 상한",
|
||||
"lowerPlaceholder": "하한값",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "파일이 너무 커요 (최대 12MB).",
|
||||
"fileType": "JPEG, PNG, WebP 또는 PDF를 올려 주세요.",
|
||||
"extractFailed": "결과지를 읽지 못했어요. 더 선명한 사진으로 시도해 주세요.",
|
||||
"pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.",
|
||||
"pdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 검사지는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있어요. 대신 사진을 올려 주세요.",
|
||||
"refTextLabel": "적힌 그대로의 범위",
|
||||
"refTextPlaceholder": "예: 3.5 - 5.0",
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다."
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.",
|
||||
"errorUnitMismatchNoUnit": "이 지표는 단위 없이 기록됩니다. 결과지를 잘못 읽었다면 단위 없음을 선택하거나 이 값의 선택을 해제하세요.",
|
||||
"unitDiffersBadgeNoUnit": "단위 없이 기록 중; 이 결과지는 {unit}"
|
||||
},
|
||||
"loadMoreReadings": "측정값 더 보기",
|
||||
"documentsLink": "검사 결과지 문서",
|
||||
"notLinkedYet": "아직 바이오마커에 연결되지 않았어요",
|
||||
"groups": {
|
||||
"other": "기타",
|
||||
"allInRange": "모두 범위 내",
|
||||
"outsideCountOne": "{total}개 중 {count}개 범위 밖",
|
||||
"outsideCountFew": "{total}개 중 {count}개 범위 밖",
|
||||
"outsideCountOther": "{total}개 중 {count}개 범위 밖",
|
||||
"heading": "카테고리",
|
||||
"description": "지질, 간처럼 패널별로 값을 묶고 그룹마다 상태를 보여줘요.",
|
||||
"label": "묶기",
|
||||
"none": "없음",
|
||||
"category": "카테고리별",
|
||||
"openLabel": "열 때 펼치기",
|
||||
"openExpanded": "모두",
|
||||
"openFlagged": "범위 밖만",
|
||||
"openCollapsed": "없음",
|
||||
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
"catalogDiffers": "내 범위 {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
|
||||
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
|
||||
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
|
||||
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
|
||||
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
|
||||
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
|
||||
},
|
||||
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "단위 없음",
|
||||
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "지원하지 않는 파일 형식이에요.",
|
||||
"purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.",
|
||||
"duplicateExists": "같은 문서가 이미 저장돼 있어요.",
|
||||
"rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.",
|
||||
"uploadBusy": "서버가 다른 업로드를 처리하느라 바빴어요. 지금 다시 시도해 주세요.",
|
||||
"rateLimited": "이번 시간의 업로드 한도에 도달했어요. 나중에 다시 시도해 주세요.",
|
||||
"generic": "업로드하지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "저장된 파일을 열어 읽지 못했어요.",
|
||||
"raster-failed": "PDF 페이지를 읽기용으로 렌더링하지 못했어요. 스캔 팩스 압축 형식이 흔한 원인이에요.",
|
||||
"provider-error": "지난번에 AI 제공자가 이 문서를 읽지 못했어요.",
|
||||
"pdf-needs-anthropic": "설정된 AI 제공자는 PDF 파일을 읽을 수 없어요. Claude 제공자나 로컬 OCR로는 읽을 수 있어요.",
|
||||
"pdf-needs-anthropic": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없고, 이 PDF에는 텍스트 레이어가 없어요. PDF 파일을 직접 받는 AI 제공자는 읽을 수 있고, 페이지 사진을 올려도 돼요.",
|
||||
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요.",
|
||||
"resource-limit": "이 PDF는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
|
||||
"errorRateLimitedWaitOther": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
|
||||
"errorBudget": "오늘 쓸 수 있는 AI 예산을 다 썼어요.",
|
||||
"errorPdfNeedsVision": "PDF를 읽으려면 Claude 비전 제공자가 필요해요.",
|
||||
"errorPdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 PDF는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있고, 페이지를 사진으로 올려도 돼요.",
|
||||
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
|
||||
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요",
|
||||
"cycleFeedCreate": "{name} 님이 캘린더 구독을 켰어요",
|
||||
"cycleFeedUpdate": "{name} 님이 캘린더 구독을 변경했어요",
|
||||
"cycleFeedRotate": "{name} 님이 캘린더 구독 링크를 다시 발급했어요",
|
||||
"cycleFeedRevoke": "{name} 님이 캘린더 구독을 껐어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "{date} 마지막 {specialty} 방문 이후",
|
||||
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
|
||||
"default": "최근 {days}일",
|
||||
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
|
||||
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
|
||||
"capped": "최근 2년",
|
||||
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "측정된 그대로의 검사 수치",
|
||||
"legendLabOutside": "참고 범위 밖",
|
||||
"legendLabRange": "참고 범위"
|
||||
"legendLabRange": "참고 범위",
|
||||
"legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값",
|
||||
"symptoms": {
|
||||
"menuTitle": "증상",
|
||||
"full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.",
|
||||
"hidden": "숨김",
|
||||
"unnamed": "증상",
|
||||
"daysOne": "1일",
|
||||
"daysFew": "{count}일 평균",
|
||||
"daysOther": "{count}일 평균"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "삶의 사건",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "필요시",
|
||||
"startsOn": "시작",
|
||||
"endsOn": "종료"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "단위가 없는 비율이나 지수(INR, 콜레스테롤/HDL)는 단위 열에 1을 입력하세요. 단위 칸이 비어 있으면 여전히 오류예요."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Hartslagvariabiliteit",
|
||||
"typeRestingHeartRate": "Rusthartslag",
|
||||
"typeActiveEnergyBurned": "Actieve energie",
|
||||
"typeBasalEnergyBurned": "Rustenergie",
|
||||
"typeFlightsClimbed": "Beklommen verdiepingen",
|
||||
"typeWalkingRunningDistance": "Afstand wandelen + hardlopen",
|
||||
"typeVo2Max": "VO₂max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Voer een duur in, bijvoorbeeld 7,5, 7:30 of 7h 30m.",
|
||||
"durationTooLong": "Eén registratie kan niet langer zijn dan 24 uur.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stressniveau",
|
||||
"typeTrainingReadiness": "Trainingsgereedheid",
|
||||
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Wat HealthLog kan bijhouden",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Gepland",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Bron",
|
||||
"intakeHistoryInjectionSite": "Injectieplaats: {site}",
|
||||
"intakeHistoryStatusTaken": "Ingenomen",
|
||||
"intakeHistoryStatusSkipped": "Overgeslagen",
|
||||
"intakeHistorySortByTaken": "Sorteren op innametijd",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "belasting"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Slaapkwaliteit",
|
||||
@@ -4777,7 +4785,11 @@
|
||||
"cardioLoadExplainer": "Een indicatie van trainingsbelasting die de acute cardiovasculaire belasting volgt die je hebt opgebouwd. Vergelijk het met je eigen recente dagen.",
|
||||
"averageHeartRateExplainer": "Je gemiddelde hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
|
||||
"maxHeartRateExplainer": "Je hoogste hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
|
||||
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert."
|
||||
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert.",
|
||||
"bodyBatteryExplainer": "Je energiereserve op de schaal van 0 tot 100 van Garmin, het hoogste niveau van de dag. Rust en slaap vullen hem aan, stress en activiteit putten hem uit.",
|
||||
"trainingReadinessExplainer": "Hoe klaar je lichaam is voor een zware training, op de schaal van 0 tot 100 van Garmin. De waarde komt via een brug die je zelf hebt ingesteld, omdat Garmin hem niet rechtstreeks deelt.",
|
||||
"stressLevelExplainer": "Garmins gemiddelde stressniveau over de dag op een schaal van 0 tot 100, afgeleid uit je hartslagvariabiliteit. Lager is rustiger.",
|
||||
"acuteTrainingLoadExplainer": "Garmins acute trainingsbelasting: de gewogen belasting van je activiteiten van de afgelopen zeven dagen. De waarde komt via een brug die je zelf hebt ingesteld. Vergelijk hem met je eigen recente weken."
|
||||
},
|
||||
"emptyAction": "Integraties openen",
|
||||
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "In deze periode is nog geen score vastgelegd.",
|
||||
"usualRange": "Gearceerd: je gebruikelijke bereik, {lo} tot {hi}.",
|
||||
"seam": "De lijn wordt onderbroken op het moment dat veranderde wat meetelt voor de score."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Per moment van de dag",
|
||||
"description": "Je gemiddelde bloeddruk per moment van de dag, over de periode van de grafiek.",
|
||||
"part": {
|
||||
"morning": "Ochtend",
|
||||
"midday": "Middag",
|
||||
"evening": "Avond"
|
||||
},
|
||||
"filterAll": "Hele dag",
|
||||
"filterLabel": "Tonen in de grafiek",
|
||||
"readingsOne": "{count} meting",
|
||||
"readingsFew": "{count} metingen",
|
||||
"readingsOther": "{count} metingen",
|
||||
"notEnough": "Te weinig metingen",
|
||||
"pulse": "Hartslag {value} bpm",
|
||||
"difference": "Ochtend min avond: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Ochtend en avond worden vergeleken zodra elk minstens {min} metingen heeft.",
|
||||
"hours": "Ochtend {morning}, middag {midday}, avond {evening}, in je eigen tijdzone.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "Geen volledige metingen in deze periode",
|
||||
"emptyDescription": "Metingen met zowel een systolische als een diastolische waarde verschijnen hier.",
|
||||
"error": "De gemiddelden per moment van de dag konden niet worden geladen.",
|
||||
"chartTitle": {
|
||||
"morning": "Bloeddruk 's ochtends",
|
||||
"midday": "Bloeddruk 's middags",
|
||||
"evening": "Bloeddruk 's avonds"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "'s ochtends",
|
||||
"midday": "'s middags",
|
||||
"evening": "'s avonds"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Gesynchroniseerd door de iOS-app, of geïmporteerd uit een export.",
|
||||
"file": "Te importeren onder Instellingen zodra je hier klaar bent."
|
||||
"file": "Te importeren onder Instellingen zodra je hier klaar bent.",
|
||||
"fitbit": "Wordt nu gekoppeld via Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Nog niet gekoppeld.",
|
||||
"openSettings": "Instellingen → Koppelingen openen",
|
||||
"noDate": "onbekend",
|
||||
"throughGoogleHealth": "{source}-gegevens komen nu binnen via {target}: de klassieke {source}-koppeling is stopgezet. Koppel {target} met het Google-account waarmee je {source} is aangemeld, dan komen je metingen daarvandaan.",
|
||||
"needsCredentials": {
|
||||
"title": "Deze instantie heeft nog geen {target}-app geregistreerd.",
|
||||
"detail": "Voer je eigen inloggegevens in onder Instellingen → Koppelingen, daarna kan de koppeling starten."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Herstel",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Slaaptekort",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stressniveau",
|
||||
"trainingReadiness": "Trainingsgereedheid",
|
||||
"acuteTrainingLoad": "Acute trainingsbelasting"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Extern apparaat",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Horloge",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} gegevenstypen, nieuwste {relative}",
|
||||
"quiet": "{count} stil",
|
||||
"workouts": "Trainingen",
|
||||
"medicationDoses": "Medicatiedoses"
|
||||
"medicationDoses": "Medicatiedoses",
|
||||
"cycleDays": "Cyclusdagen",
|
||||
"cycleDaysCount": "Cyclusdagen ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Ingesteld op dit account zonder eigen instellingenkaart."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Laatst hier ingevoerd {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin heeft hier geen directe koppeling. Koppel via Apple Health (iOS) of Google Health Connect (Android). De eigen scores van Garmin (Body Battery, Training Load, HRV Status, VO₂ max) deelt Garmin met geen van beide platforms.",
|
||||
"docsLink": "Lees de handleiding"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Referentiebereik voor glucose",
|
||||
"description": "Aan welk bereik je bloedglucosemetingen worden getoetst.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Je wordt uitgelogd en je account wordt meteen vergrendeld. Na {days} dagen wordt het definitief gewist, met alle gezondheidsgegevens, passkeys, sessies en auditregels. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
|
||||
"deleteAccountScheduled": "Je account is vergrendeld en wordt op {date} gewist. Je wordt uitgelogd.",
|
||||
"dangerZoneSuccessGrace": "Je gezondheidsdossier is verwijderd. Een beheerder van deze server kan het tot {date} terugzetten.",
|
||||
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld."
|
||||
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Plak een Garmin Personal API-sleutel om slaap, HRV, Body Battery, stress, stappen, gewicht en activiteiten uit Garmin Connect te synchroniseren.",
|
||||
"garminSync": "Nu synchroniseren",
|
||||
"garminSyncResult": "{count} items gesynchroniseerd",
|
||||
"garminSyncFailed": "Synchronisatie mislukt",
|
||||
"garminViewData": "Je herstel bekijken",
|
||||
"garminReplaceKey": "Sleutel vervangen",
|
||||
"garminDisconnect": "Ontkoppelen",
|
||||
"garminDisconnectTitle": "Garmin ontkoppelen?",
|
||||
"garminDisconnectDescription": "Toekomstige synchronisaties stoppen en de sleutel wordt van deze server verwijderd. Al geïmporteerde gegevens blijven in je geschiedenis. Bij Garmin blijft de sleutel werken tot je hem daar intrekt.",
|
||||
"garminKeyValidUntil": "Sleutel geldig tot {date}",
|
||||
"garminKeyExpired": "Je Garmin-sleutel is op {date} verlopen. Maak in Garmin Connect een nieuwe sleutel en plak die hier om weer te synchroniseren.",
|
||||
"garminKeyExpiresToday": "Je Garmin-sleutel verloopt vandaag. Maak in Garmin Connect een nieuwe sleutel en plak die hier zodat het synchroniseren niet stopt.",
|
||||
"garminKeyExpiring": "Je Garmin-sleutel verloopt op {date}. Maak vóór die datum in Garmin Connect een nieuwe sleutel en plak die hier.",
|
||||
"garminKeyExpiryUnknown": "Garmin vertelt niet wanneer deze sleutel verloopt. Vervang de sleutel en kies de geldigheid die je in Garmin Connect hebt gekozen, dan waarschuwt deze kaart je een week van tevoren.",
|
||||
"garminConnected": "Garmin gekoppeld. De eerste synchronisatie is onderweg.",
|
||||
"garminKeyHowTo": "Open in Garmin Connect de instellingen, dan Personal API, en maak een sleutel. Garmin toont hem maar één keer, dus kopieer hem meteen.",
|
||||
"garminKeySettingsLink": "Garmin Connect openen",
|
||||
"garminApiKey": "Personal API-sleutel",
|
||||
"garminApiKeyPlaceholder": "Plak de sleutel",
|
||||
"garminApiKeySavedPlaceholder": "Opgeslagen. Plak een nieuwe sleutel om hem te vervangen",
|
||||
"garminKeyValidity": "Gekozen geldigheid",
|
||||
"garminKeyValidityUnknown": "Weet ik niet",
|
||||
"garminKeyValidityDays": "{count} dagen",
|
||||
"garminKeyValidityHelp": "Garmin meldt niet wanneer een sleutel verloopt. Met de geldigheid waarschuwt deze kaart je een week voordat de sleutel stopt met werken.",
|
||||
"garminConnect": "Koppelen",
|
||||
"garminUpdate": "Wijzigingen opslaan",
|
||||
"garminRegion": "Garmin-dienst",
|
||||
"garminRegionGlobal": "Garmin Connect (wereldwijd)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin heeft deze sleutel niet geaccepteerd. Controleer of hij volledig is gekopieerd, bij de gekozen dienst hoort en niet verlopen of ingetrokken is.",
|
||||
"rateLimited": "Garmin beperkt op dit moment het aantal verzoeken. Probeer het over een paar minuten opnieuw.",
|
||||
"unreachable": "Garmin is niet bereikbaar. Probeer het over een paar minuten opnieuw.",
|
||||
"invalid": "Die sleutel ziet er niet goed uit. Plak hem opnieuw, zonder spaties.",
|
||||
"generic": "Dat is niet gelukt. Probeer het opnieuw."
|
||||
},
|
||||
"garminKeyValidityMenu": "Geldigheid van de sleutel",
|
||||
"garminValiditySaved": "Geldigheid opgeslagen.",
|
||||
"garminKeyCreatedOn": "Aangemaakt op",
|
||||
"garminKeyCreatedOnHelp": "De dag waarop je de sleutel in Garmin Connect hebt gemaakt. Leeg gelaten telt de geldigheid vanaf de dag dat je hem hier hebt gekoppeld.",
|
||||
"garminUnavailable": "Garmin levert deze gegevens niet voor je account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "dagoverzichten",
|
||||
"activities": "activiteiten",
|
||||
"sleep": "slaap",
|
||||
"hrv": "HRV",
|
||||
"weight": "wegingen",
|
||||
"cycle": "menstruatiecyclus"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Beheer",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mijn menstruatie begon op deze dag",
|
||||
"endedPeriodOnDate": "Mijn menstruatie eindigde op deze dag",
|
||||
"startAbsorbed": "Begin van menstruatie opgeslagen. Je begin op {absorbed} is samengevoegd met {date}.",
|
||||
"startRestored": "Opgeslagen. Het begin op {dates} is terug."
|
||||
"startRestored": "Opgeslagen. Het begin op {dates} is terug.",
|
||||
"feed": {
|
||||
"title": "Agenda-abonnement",
|
||||
"description": "Je volgende menstruaties als afspraken voor de hele dag in de agenda-app die je al gebruikt.",
|
||||
"disclosure": "Je agendaprovider (Google, iCloud, Outlook) bewaart deze datums op zijn servers. Als je dit later uitzet, blijven kopieën die hij al heeft daar staan.",
|
||||
"turnOn": "Abonnement aanzetten",
|
||||
"error": "Het agenda-abonnement kon niet worden gewijzigd. Probeer het opnieuw.",
|
||||
"linkHidden": "Er is een link actief die begint met {prefix}. Die wordt maar één keer getoond, bij het aanmaken; vernieuw hem voor een nieuwe.",
|
||||
"linkLabel": "Abonnementslink",
|
||||
"linkOnce": "Alleen nu zichtbaar. Plak hem in je agenda-app, of vernieuw hem later voor een nieuwe.",
|
||||
"copy": "Link kopiëren",
|
||||
"copied": "Link gekopieerd.",
|
||||
"copyFailed": "Kopiëren is niet gelukt. Selecteer de link en kopieer hem zelf.",
|
||||
"openInCalendar": "Openen in agenda-app",
|
||||
"notFetched": "Nog niet opgehaald.",
|
||||
"lastFetched": "Laatst opgehaald {when} door {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Apple Agenda",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "een agenda-app"
|
||||
},
|
||||
"horizon": "Cycli vooruit",
|
||||
"history": "Eerdere cycli",
|
||||
"historyNone": "Geen",
|
||||
"titles": "Titels van afspraken",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreet (●)",
|
||||
"DESCRIPTIVE": "Beschrijvend"
|
||||
},
|
||||
"fertile": "Vruchtbare periode",
|
||||
"fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.",
|
||||
"rotate": "Link vernieuwen",
|
||||
"rotateTitle": "Link vernieuwen?",
|
||||
"rotateBody": "Agenda's met de oude link worden niet meer bijgewerkt. Je krijgt een nieuwe link om in plaats daarvan toe te voegen.",
|
||||
"turnOff": "Uitzetten",
|
||||
"turnOffTitle": "Agenda-abonnement uitzetten?",
|
||||
"turnOffBody": "De link werkt niet meer. Kopieën die je agendaprovider al heeft, blijven staan tot je de agenda daar verwijdert.",
|
||||
"help": {
|
||||
"title": "Toevoegen aan je agenda",
|
||||
"google": "Google Agenda: Andere agenda's, Via URL, en plak de link. Google ververst ongeveer elke 12 tot 24 uur.",
|
||||
"apple": "Apple Agenda: op de Mac Archief, Nieuw agenda-abonnement; op de iPhone Instellingen, Agenda, Accounts, Voeg account toe, Andere, Voeg agenda-abonnement toe.",
|
||||
"outlook": "Outlook: Agenda toevoegen, Abonneren vanaf internet, en plak de link. Outlook ververst om de paar uur."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cyclus (HealthLog)",
|
||||
"periodEstimate": "Menstruatie (schatting)",
|
||||
"periodLogged": "Menstruatie (geregistreerd)",
|
||||
"fertileEstimate": "Vruchtbare periode (schatting)",
|
||||
"likelyStart": "Meest waarschijnlijke begin {date}, {band}",
|
||||
"bandOne": "±{count} dag",
|
||||
"bandFew": "±{count} dagen",
|
||||
"bandOther": "±{count} dagen"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Latere cycli",
|
||||
"around": "Rond {date}",
|
||||
"state": {
|
||||
"LEARNING": "Tot er 3 cycli zijn geregistreerd, is alleen de volgende menstruatie opgenomen.",
|
||||
"LATE": "Geen voorspelling zolang deze menstruatie later is dan verwacht; die komt terug zodra je haar registreert.",
|
||||
"OFF": "Voorspellingen staan uit; alleen eerdere cycli zijn opgenomen, als je die hebt gekozen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Menstruatie waarschijnlijk binnenkort",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Zodra deze marker metingen heeft, blijft de eenheid zoals die is. Anders zouden de opgeslagen waarden zonder omrekening in een nieuwe eenheid worden getoond.",
|
||||
"panel": "Groep",
|
||||
"panelPlaceholder": "bijv. Lipiden",
|
||||
"panelHint": "Kies een suggestie of typ je eigen groep. Groepen uit de catalogus worden in elke taal herkend.",
|
||||
"lowerBound": "Referentie ondergrens",
|
||||
"upperBound": "Referentie bovengrens",
|
||||
"lowerPlaceholder": "Ondergrens",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "Het bestand is te groot (max. 12 MB).",
|
||||
"fileType": "Upload een JPEG, PNG, WebP of PDF.",
|
||||
"extractFailed": "Het rapport kon niet worden gelezen. Probeer een scherpere foto.",
|
||||
"pdfNeedsAnthropic": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig; upload in plaats daarvan een foto.",
|
||||
"pdfRendererUnavailable": "De processor van deze server kan geen PDF-pagina's weergeven, dus een gescand rapport kan hier alleen worden gelezen door een AI-provider die PDF-bestanden rechtstreeks aanneemt. Upload in plaats daarvan een foto.",
|
||||
"refTextLabel": "Bereik zoals afgedrukt",
|
||||
"refTextPlaceholder": "bijv. 3,5 - 5,0",
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar."
|
||||
"aiUnavailable": "Een rapport scannen met AI is hier op dit moment niet beschikbaar.",
|
||||
"errorUnitMismatchNoUnit": "Deze marker wordt zonder eenheid bijgehouden. Vink Geen eenheid aan als het rapport verkeerd is gelezen, of deselecteer deze meting.",
|
||||
"unitDiffersBadgeNoUnit": "Zonder eenheid bijgehouden; dit rapport vermeldt {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Meer metingen laden",
|
||||
"documentsLink": "Documenten met labrapporten",
|
||||
"notLinkedYet": "Nog niet gekoppeld aan een biomarker",
|
||||
"groups": {
|
||||
"other": "Overig",
|
||||
"allInRange": "Alles binnen bereik",
|
||||
"outsideCountOne": "{count} van {total} buiten bereik",
|
||||
"outsideCountFew": "{count} van {total} buiten bereik",
|
||||
"outsideCountOther": "{count} van {total} buiten bereik",
|
||||
"heading": "Categorieën",
|
||||
"description": "Groepeert de waarden per groep, zoals Lipiden of Lever, met een status per groep.",
|
||||
"label": "Groeperen",
|
||||
"none": "Niet",
|
||||
"category": "Per categorie",
|
||||
"openLabel": "Open bij laden",
|
||||
"openExpanded": "Alle",
|
||||
"openFlagged": "Alleen buiten bereik",
|
||||
"openCollapsed": "Geen",
|
||||
"orderWithinGroups": "De volgorde geldt binnen elke categorie."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Bereik volgens rapport: {range}",
|
||||
"catalogDiffers": "jouw bereik: {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} metingen, omgerekend van {unit} naar {targetUnit}",
|
||||
"refused": "{unit} kan niet worden omgerekend naar {targetUnit}.",
|
||||
"aliasesAdded": "Deze namen verwijzen voortaan naar de behouden biomarker:",
|
||||
"adoptsPanel": "De behouden biomarker neemt de groep {panel} over.",
|
||||
"blocked": "Er kan niets worden samengevoegd zolang een eenheid niet kan worden omgerekend. Laat die biomarker weg of behoud een andere.",
|
||||
"noUndo": "Samenvoegen kan niet ongedaan worden gemaakt.",
|
||||
"doneToastOne": "Samengevoegd in {name}, {count} meting verplaatst",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie."
|
||||
},
|
||||
"interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Geen eenheid",
|
||||
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Dit bestandstype wordt niet ondersteund.",
|
||||
"purged": "Dit document is definitief verwijderd en kan niet worden hersteld.",
|
||||
"duplicateExists": "Er is al een identiek document opgeslagen.",
|
||||
"rateLimited": "Te veel uploads op dit moment. Probeer het over een paar minuten opnieuw.",
|
||||
"uploadBusy": "De server was bezig met andere uploads. Probeer het nu opnieuw.",
|
||||
"rateLimited": "Je hebt de uploadlimiet voor dit uur bereikt. Probeer het later opnieuw.",
|
||||
"generic": "Uploaden is mislukt. Probeer het opnieuw."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "Het opgeslagen bestand kon niet worden geopend om te lezen.",
|
||||
"raster-failed": "De PDF-pagina's konden niet worden weergegeven om te lezen. Compressieformaten van gescande faxen zijn een bekende oorzaak.",
|
||||
"provider-error": "De AI-provider kon dit document bij de laatste poging niet lezen.",
|
||||
"pdf-needs-anthropic": "De ingestelde AI-provider kan geen PDF-bestanden lezen. Een Claude-provider of lokale OCR kan dat wel.",
|
||||
"pdf-needs-anthropic": "De processor van deze server kan geen PDF-pagina's weergeven en deze PDF heeft geen tekstlaag. Een AI-provider die PDF-bestanden rechtstreeks aanneemt kan hem lezen, of upload foto's van de pagina's.",
|
||||
"empty-transcription": "De AI-provider gaf geen tekst terug voor dit document.",
|
||||
"resource-limit": "Deze pdf is te groot of te complex om hier te lezen. Het bestand zelf is in orde en opent nog steeds."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "De limiet voor lezen met AI per uur is bereikt. Probeer het over ongeveer {count} minuten opnieuw.",
|
||||
"errorRateLimitedWaitOther": "De limiet voor lezen met AI per uur is bereikt. Probeer het over ongeveer {count} minuten opnieuw.",
|
||||
"errorBudget": "Je AI-budget voor vandaag is op.",
|
||||
"errorPdfNeedsVision": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig.",
|
||||
"errorPdfRendererUnavailable": "De processor van deze server kan geen PDF-pagina's weergeven, dus een gescande PDF kan hier alleen worden gelezen door een AI-provider die PDF-bestanden rechtstreeks aanneemt, of als foto's van de pagina's.",
|
||||
"errorFileType": "Dit bestandstype kan niet worden gelezen.",
|
||||
"errorNotIndexed": "Laat het document eerst lezen en extraheer daarna.",
|
||||
"errorOutputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een model dat langere antwoorden toestaat.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} heeft biomarkers samengevoegd",
|
||||
"medicationIngredientsUpdate": "{name} heeft de samenstelling van een supplement gewijzigd",
|
||||
"auditCCreate": "{name} heeft de AUDIT-C-vragen beantwoord",
|
||||
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd"
|
||||
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd",
|
||||
"cycleFeedCreate": "{name} heeft het agenda-abonnement aangezet",
|
||||
"cycleFeedUpdate": "{name} heeft het agenda-abonnement gewijzigd",
|
||||
"cycleFeedRotate": "{name} heeft de link van het agenda-abonnement vernieuwd",
|
||||
"cycleFeedRevoke": "{name} heeft het agenda-abonnement uitgezet"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Sinds je laatste bezoek ({specialty}) op {date}",
|
||||
"kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}",
|
||||
"default": "De afgelopen {days} dagen",
|
||||
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort."
|
||||
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
|
||||
"capped": "De afgelopen twee jaar",
|
||||
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Labwaarde, zoals gemeten",
|
||||
"legendLabOutside": "Buiten het referentiebereik",
|
||||
"legendLabRange": "Referentiebereik"
|
||||
"legendLabRange": "Referentiebereik",
|
||||
"legendSymptom": "Symptoomlijn: de hoogste intensiteit van elke vastgelegde dag (0 tot 10), gemiddeld over de vastgelegde dagen",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptomen",
|
||||
"full": "Maximaal {max} symptomen tegelijk. Verwijder er een om een ander toe te voegen.",
|
||||
"hidden": "verborgen",
|
||||
"unnamed": "Symptoom",
|
||||
"daysOne": "op 1 dag",
|
||||
"daysFew": "gemiddelde van {count} dagen",
|
||||
"daysOther": "gemiddelde van {count} dagen"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Levensgebeurtenis",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "Zo nodig",
|
||||
"startsOn": "Begin",
|
||||
"endsOn": "Einde"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Een verhouding of index zonder eenheid (INR, cholesterol/HDL): vul 1 in de eenheidskolom in. Een lege eenheid blijft een fout."
|
||||
}
|
||||
}
|
||||
+231
-23
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Zmienność rytmu serca",
|
||||
"typeRestingHeartRate": "Tętno spoczynkowe",
|
||||
"typeActiveEnergyBurned": "Aktywna energia",
|
||||
"typeBasalEnergyBurned": "Energia spoczynkowa",
|
||||
"typeFlightsClimbed": "Pokonane piętra",
|
||||
"typeWalkingRunningDistance": "Dystans marszu i biegu",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
|
||||
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Poziom stresu",
|
||||
"typeTrainingReadiness": "Gotowość do treningu",
|
||||
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Zaplanowany",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Źródło",
|
||||
"intakeHistoryInjectionSite": "Miejsce wstrzyknięcia: {site}",
|
||||
"intakeHistoryStatusTaken": "Przyjęty",
|
||||
"intakeHistoryStatusSkipped": "Pominięty",
|
||||
"intakeHistorySortByTaken": "Sortuj wg czasu przyjęcia",
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "pkt",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ud./min",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "obciążenie"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Jakość snu",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
|
||||
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
|
||||
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia.",
|
||||
"bodyBatteryExplainer": "Twoja rezerwa energii w skali Garmina od 0 do 100, najwyższy poziom dnia. Odpoczynek i sen ją uzupełniają, stres i aktywność ją zużywają.",
|
||||
"trainingReadinessExplainer": "Jak bardzo Twoje ciało jest gotowe na ciężki trening, w skali Garmina od 0 do 100. Wartość przychodzi przez skonfigurowany przez Ciebie most, bo Garmin nie udostępnia jej bezpośrednio.",
|
||||
"stressLevelExplainer": "Średni całodzienny poziom stresu według Garmina w skali od 0 do 100, wyliczany ze zmienności rytmu serca. Niżej znaczy spokojniej.",
|
||||
"acuteTrainingLoadExplainer": "Ostre obciążenie treningowe Garmina: ważone obciążenie z Twoich aktywności z ostatnich siedmiu dni. Wartość przychodzi przez skonfigurowany przez Ciebie most. Porównuj je ze swoimi ostatnimi tygodniami."
|
||||
},
|
||||
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
|
||||
},
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "W tym okresie nie zapisano jeszcze żadnego wyniku.",
|
||||
"usualRange": "Zacieniowane: twój zwykły zakres, od {lo} do {hi}.",
|
||||
"seam": "Linia przerywa się tam, gdzie zmieniło się to, co liczy się do wyniku."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Według pory dnia",
|
||||
"description": "Twoje średnie ciśnienie w każdej porze dnia, w zakresie wykresu.",
|
||||
"part": {
|
||||
"morning": "Rano",
|
||||
"midday": "W południe",
|
||||
"evening": "Wieczorem"
|
||||
},
|
||||
"filterAll": "Cały dzień",
|
||||
"filterLabel": "Pokaż na wykresie",
|
||||
"readingsOne": "{count} pomiar",
|
||||
"readingsFew": "{count} pomiary",
|
||||
"readingsOther": "{count} pomiarów",
|
||||
"notEnough": "Za mało pomiarów",
|
||||
"pulse": "Tętno {value} bpm",
|
||||
"difference": "Rano minus wieczór: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Rano i wieczór zostaną porównane, gdy każda z tych pór będzie mieć co najmniej {min} pomiary.",
|
||||
"hours": "Rano {morning}, w południe {midday}, wieczorem {evening}, w Twojej strefie czasowej.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "Brak pełnych pomiarów w tym zakresie",
|
||||
"emptyDescription": "Tutaj pojawią się pomiary z wartością skurczową i rozkurczową.",
|
||||
"error": "Nie udało się wczytać średnich według pory dnia.",
|
||||
"chartTitle": {
|
||||
"morning": "Ciśnienie tętnicze rano",
|
||||
"midday": "Ciśnienie tętnicze w południe",
|
||||
"evening": "Ciśnienie tętnicze wieczorem"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "rano",
|
||||
"midday": "w południe",
|
||||
"evening": "wieczorem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Synchronizowane przez aplikację iOS lub importowane z eksportu.",
|
||||
"file": "Import w Ustawieniach, gdy skończysz tutaj."
|
||||
"file": "Import w Ustawieniach, gdy skończysz tutaj.",
|
||||
"fitbit": "Teraz łączy się przez Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Jeszcze nie połączono.",
|
||||
"openSettings": "Otwórz Ustawienia → Integracje",
|
||||
"noDate": "nieznany",
|
||||
"throughGoogleHealth": "Dane z {source} trafiają teraz przez {target}: klasyczne połączenie z {source} zostało wyłączone. Połącz {target} z kontem Google, na którym zalogowany jest Twój {source}, a pomiary zaczną napływać stamtąd.",
|
||||
"needsCredentials": {
|
||||
"title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.",
|
||||
"detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć."
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
"sleepDebt": "Dług snu",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Poziom stresu",
|
||||
"trainingReadiness": "Gotowość do treningu",
|
||||
"acuteTrainingLoad": "Ostre obciążenie treningowe"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Urządzenie zewnętrzne",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Zegarek",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} typów danych, najnowsze {relative}",
|
||||
"quiet": "{count} bez danych",
|
||||
"workouts": "Treningi",
|
||||
"medicationDoses": "Dawki leków"
|
||||
"medicationDoses": "Dawki leków",
|
||||
"cycleDays": "Dni cyklu",
|
||||
"cycleDaysCount": "Dni cyklu ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina (Body Battery, Training Load, status HRV, VO₂ max) nie są udostępniane przez Garmina żadnej z tych platform.",
|
||||
"docsLink": "Przeczytaj przewodnik"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Zakres referencyjny glukozy",
|
||||
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Zostaniesz wylogowany, a twoje konto od razu zablokowane. Po {days} dniach zostanie trwale usunięte, razem ze wszystkimi danymi zdrowotnymi, kluczami dostępu, sesjami i wpisami audytu. Do tego czasu administrator tego serwera może cofnąć usunięcie. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
|
||||
"deleteAccountScheduled": "Twoje konto jest zablokowane i zostanie usunięte {date}. Trwa wylogowywanie.",
|
||||
"dangerZoneSuccessGrace": "Twoja dokumentacja zdrowotna została usunięta. Administrator tego serwera może ją przywrócić do {date}.",
|
||||
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa."
|
||||
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Wklej klucz Garmin Personal API, aby synchronizować sen, HRV, Body Battery, stres, kroki, wagę i aktywności z Garmin Connect.",
|
||||
"garminSync": "Synchronizuj teraz",
|
||||
"garminSyncResult": "Zsynchronizowano wpisy: {count}",
|
||||
"garminSyncFailed": "Synchronizacja nie powiodła się",
|
||||
"garminViewData": "Zobacz regenerację",
|
||||
"garminReplaceKey": "Zastąp klucz",
|
||||
"garminDisconnect": "Odłącz",
|
||||
"garminDisconnectTitle": "Odłączyć Garmin?",
|
||||
"garminDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane, a klucz usunięty z tego serwera. Zaimportowane dane zostają w Twojej historii. Klucz działa w Garminie, dopóki go tam nie unieważnisz.",
|
||||
"garminKeyValidUntil": "Klucz ważny do {date}",
|
||||
"garminKeyExpired": "Twój klucz Garmin wygasł {date}. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby wznowić synchronizację.",
|
||||
"garminKeyExpiresToday": "Twój klucz Garmin wygasa dzisiaj. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby synchronizacja się nie zatrzymała.",
|
||||
"garminKeyExpiring": "Twój klucz Garmin wygasa {date}. Przed tym dniem utwórz nowy klucz w Garmin Connect i wklej go tutaj.",
|
||||
"garminKeyExpiryUnknown": "Garmin nie podaje, kiedy ten klucz wygaśnie. Zastąp klucz i wybierz ważność ustawioną w Garmin Connect, a ta karta ostrzeże Cię tydzień wcześniej.",
|
||||
"garminConnected": "Połączono z Garmin. Pierwsza synchronizacja jest w drodze.",
|
||||
"garminKeyHowTo": "W Garmin Connect otwórz Ustawienia, następnie Personal API, i utwórz klucz. Garmin pokazuje go tylko raz, więc od razu go skopiuj.",
|
||||
"garminKeySettingsLink": "Otwórz Garmin Connect",
|
||||
"garminApiKey": "Klucz Personal API",
|
||||
"garminApiKeyPlaceholder": "Wklej klucz",
|
||||
"garminApiKeySavedPlaceholder": "Zapisano. Wklej nowy klucz, aby go zastąpić",
|
||||
"garminKeyValidity": "Wybrana ważność",
|
||||
"garminKeyValidityUnknown": "Nie wiem",
|
||||
"garminKeyValidityDays": "{count} dni",
|
||||
"garminKeyValidityHelp": "Garmin nie informuje, kiedy klucz wygasa. Dzięki ważności ta karta ostrzeże Cię tydzień przed tym, jak klucz przestanie działać.",
|
||||
"garminConnect": "Połącz",
|
||||
"garminUpdate": "Zapisz zmiany",
|
||||
"garminRegion": "Usługa Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (globalny)",
|
||||
"garminRegionChina": "Garmin Connect Chiny",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin nie przyjął tego klucza. Sprawdź, czy został skopiowany w całości, należy do wybranej usługi i nie wygasł ani nie został unieważniony.",
|
||||
"rateLimited": "Garmin ogranicza teraz liczbę zapytań. Spróbuj ponownie za kilka minut.",
|
||||
"unreachable": "Nie można połączyć się z Garmin. Spróbuj ponownie za kilka minut.",
|
||||
"invalid": "Ten klucz wygląda nieprawidłowo. Wklej go ponownie, bez spacji.",
|
||||
"generic": "Nie udało się. Spróbuj ponownie."
|
||||
},
|
||||
"garminKeyValidityMenu": "Ważność klucza",
|
||||
"garminValiditySaved": "Zapisano ważność.",
|
||||
"garminKeyCreatedOn": "Utworzono",
|
||||
"garminKeyCreatedOnHelp": "Dzień, w którym utworzono klucz w Garmin Connect. Jeśli zostawisz puste, ważność liczy się od dnia połączenia go tutaj.",
|
||||
"garminUnavailable": "Garmin nie udostępnia tych danych dla Twojego konta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "podsumowania dzienne",
|
||||
"activities": "aktywności",
|
||||
"sleep": "sen",
|
||||
"hrv": "HRV",
|
||||
"weight": "pomiary wagi",
|
||||
"cycle": "cykl menstruacyjny"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
|
||||
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
|
||||
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
|
||||
"startRestored": "Zapisano. Początek z {dates} jest z powrotem.",
|
||||
"feed": {
|
||||
"title": "Subskrypcja kalendarza",
|
||||
"description": "Twoje kolejne miesiączki jako wydarzenia całodniowe w aplikacji kalendarza, której już używasz.",
|
||||
"disclosure": "Twój dostawca kalendarza (Google, iCloud, Outlook) przechowuje te daty na swoich serwerach. Późniejsze wyłączenie nie usuwa kopii, które już ma.",
|
||||
"turnOn": "Włącz subskrypcję",
|
||||
"error": "Nie udało się zmienić subskrypcji kalendarza. Spróbuj ponownie.",
|
||||
"linkHidden": "Aktywny jest link zaczynający się od {prefix}. Jest pokazywany tylko raz, przy utworzeniu; odnów go, aby dostać nowy.",
|
||||
"linkLabel": "Link subskrypcji",
|
||||
"linkOnce": "Widoczny tylko teraz. Wklej go do aplikacji kalendarza albo odnów go później, aby dostać nowy.",
|
||||
"copy": "Kopiuj link",
|
||||
"copied": "Link skopiowany.",
|
||||
"copyFailed": "Kopiowanie nie zadziałało. Zaznacz link i skopiuj go ręcznie.",
|
||||
"openInCalendar": "Otwórz w aplikacji kalendarza",
|
||||
"notFetched": "Jeszcze nie pobrano.",
|
||||
"lastFetched": "Ostatnio pobrane {when} przez: {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Kalendarz Google",
|
||||
"APPLE": "Kalendarz Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "aplikacja kalendarza"
|
||||
},
|
||||
"horizon": "Cykle do przodu",
|
||||
"history": "Poprzednie cykle",
|
||||
"historyNone": "Brak",
|
||||
"titles": "Tytuły wydarzeń",
|
||||
"titleMode": {
|
||||
"DISCREET": "Dyskretne (●)",
|
||||
"DESCRIPTIVE": "Opisowe"
|
||||
},
|
||||
"fertile": "Okno płodne",
|
||||
"fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.",
|
||||
"rotate": "Odnów link",
|
||||
"rotateTitle": "Odnowić link?",
|
||||
"rotateBody": "Kalendarze ze starym linkiem przestaną się aktualizować. Dostaniesz nowy link, który dodasz zamiast niego.",
|
||||
"turnOff": "Wyłącz",
|
||||
"turnOffTitle": "Wyłączyć subskrypcję kalendarza?",
|
||||
"turnOffBody": "Link przestanie działać. Kopie, które ma już Twój dostawca kalendarza, zostaną, dopóki nie usuniesz tam kalendarza.",
|
||||
"help": {
|
||||
"title": "Jak dodać go do kalendarza",
|
||||
"google": "Kalendarz Google: Inne kalendarze, Z adresu URL, potem wklej link. Google odświeża mniej więcej co 12 do 24 godzin.",
|
||||
"apple": "Kalendarz Apple: na Macu Plik, Nowa subskrypcja kalendarza; na iPhonie Ustawienia, Kalendarz, Konta, Dodaj konto, Inne, Dodaj subskrybowany kalendarz.",
|
||||
"outlook": "Outlook: Dodaj kalendarz, Subskrybuj z sieci Web, potem wklej link. Outlook odświeża co kilka godzin."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cykl (HealthLog)",
|
||||
"periodEstimate": "Miesiączka (szacunek)",
|
||||
"periodLogged": "Miesiączka (zapisana)",
|
||||
"fertileEstimate": "Okno płodne (szacunek)",
|
||||
"likelyStart": "Najbardziej prawdopodobny początek {date}, {band}",
|
||||
"bandOne": "±{count} dzień",
|
||||
"bandFew": "±{count} dni",
|
||||
"bandOther": "±{count} dni"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Kolejne cykle",
|
||||
"around": "Około {date}",
|
||||
"state": {
|
||||
"LEARNING": "Dopóki nie zapiszesz 3 cykli, uwzględniana jest tylko najbliższa miesiączka.",
|
||||
"LATE": "Brak prognozy, dopóki ta miesiączka spóźnia się bardziej niż oczekiwano; wróci, gdy ją zapiszesz.",
|
||||
"OFF": "Prognozy są wyłączone; uwzględniane są tylko poprzednie cykle, jeśli je wybrano."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "np. Lipidy",
|
||||
"panelHint": "Wybierz podpowiedź lub wpisz własny. Panele z katalogu są rozpoznawane w każdym języku.",
|
||||
"lowerBound": "Dolna granica",
|
||||
"upperBound": "Górna granica",
|
||||
"lowerPlaceholder": "Dolna granica",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "Plik jest za duży (maks. 12 MB).",
|
||||
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
|
||||
"extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
|
||||
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
|
||||
"pdfRendererUnavailable": "Procesor tego serwera nie potrafi renderować stron PDF, więc zeskanowany wynik odczyta tu tylko dostawca AI, który przyjmuje pliki PDF bezpośrednio. Prześlij zamiast tego zdjęcie.",
|
||||
"refTextLabel": "Zakres tak jak wydrukowano",
|
||||
"refTextPlaceholder": "np. 3,5 - 5,0",
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne."
|
||||
"aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne.",
|
||||
"errorUnitMismatchNoUnit": "Ten wskaźnik jest prowadzony bez jednostki. Zaznacz Bez jednostki, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
|
||||
"unitDiffersBadgeNoUnit": "Prowadzony bez jednostki; ten wynik podaje {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Wczytaj więcej pomiarów",
|
||||
"documentsLink": "Dokumenty wyników badań",
|
||||
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem",
|
||||
"groups": {
|
||||
"other": "Inne",
|
||||
"allInRange": "Wszystkie w normie",
|
||||
"outsideCountOne": "{count} z {total} poza normą",
|
||||
"outsideCountFew": "{count} z {total} poza normą",
|
||||
"outsideCountOther": "{count} z {total} poza normą",
|
||||
"heading": "Kategorie",
|
||||
"description": "Grupuje wartości według panelu, np. Lipidy lub Wątroba, ze statusem dla każdej grupy.",
|
||||
"label": "Grupowanie",
|
||||
"none": "Brak",
|
||||
"category": "Według kategorii",
|
||||
"openLabel": "Rozwinięte po otwarciu",
|
||||
"openExpanded": "Wszystkie",
|
||||
"openFlagged": "Tylko poza normą",
|
||||
"openCollapsed": "Żadne",
|
||||
"orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
"catalogDiffers": "twój zakres {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}",
|
||||
"refused": "Nie można przeliczyć {unit} na {targetUnit}.",
|
||||
"aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:",
|
||||
"adoptsPanel": "Zachowany biomarker przejmuje panel {panel}.",
|
||||
"blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.",
|
||||
"noUndo": "Scalenia nie można cofnąć.",
|
||||
"doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji."
|
||||
},
|
||||
"interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Bez jednostki",
|
||||
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Ten typ pliku nie jest obsługiwany.",
|
||||
"purged": "Ten dokument został trwale usunięty i nie można go przywrócić.",
|
||||
"duplicateExists": "Identyczny dokument jest już zapisany.",
|
||||
"rateLimited": "Zbyt wiele przesyłań. Spróbuj ponownie za kilka minut.",
|
||||
"uploadBusy": "Serwer był zajęty innymi przesyłaniami. Spróbuj ponownie teraz.",
|
||||
"rateLimited": "Osiągnięto limit przesyłań na tę godzinę. Spróbuj ponownie później.",
|
||||
"generic": "Przesyłanie nie powiodło się. Spróbuj ponownie."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "Nie udało się otworzyć zapisanego pliku do odczytu.",
|
||||
"raster-failed": "Nie udało się przetworzyć stron PDF do odczytu. Znaną przyczyną są skany w faksowych formatach kompresji.",
|
||||
"provider-error": "Dostawca AI nie zdołał odczytać tego dokumentu przy ostatniej próbie.",
|
||||
"pdf-needs-anthropic": "Skonfigurowany dostawca AI nie odczytuje plików PDF. Poradzi sobie z tym dostawca Claude lub lokalny OCR.",
|
||||
"pdf-needs-anthropic": "Procesor tego serwera nie potrafi renderować stron PDF, a ten PDF nie ma warstwy tekstowej. Odczyta go dostawca AI, który przyjmuje pliki PDF bezpośrednio, albo prześlij zdjęcia stron.",
|
||||
"empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu.",
|
||||
"resource-limit": "Ten PDF jest zbyt duży lub zbyt złożony, żeby odczytać go tutaj. Sam plik jest w porządku i nadal się otwiera."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minuty.",
|
||||
"errorRateLimitedWaitOther": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minut.",
|
||||
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
|
||||
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
|
||||
"errorPdfRendererUnavailable": "Procesor tego serwera nie potrafi renderować stron PDF, więc zeskanowany PDF odczyta tu tylko dostawca AI, który przyjmuje pliki PDF bezpośrednio, albo prześlij zdjęcia stron.",
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
|
||||
"errorOutputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj modelu, który pozwala na dłuższe odpowiedzi.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} scalił biomarkery",
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
|
||||
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C"
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C",
|
||||
"cycleFeedCreate": "{name} włączył subskrypcję kalendarza",
|
||||
"cycleFeedUpdate": "{name} zmienił subskrypcję kalendarza",
|
||||
"cycleFeedRotate": "{name} odnowił link subskrypcji kalendarza",
|
||||
"cycleFeedRevoke": "{name} wyłączył subskrypcję kalendarza"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Od ostatniej wizyty ({specialty}) {date}",
|
||||
"kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}",
|
||||
"default": "Ostatnie {days} dni",
|
||||
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju."
|
||||
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
|
||||
"capped": "Ostatnie dwa lata",
|
||||
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Wynik badania, tak jak zmierzono",
|
||||
"legendLabOutside": "Poza zakresem referencyjnym",
|
||||
"legendLabRange": "Zakres referencyjny"
|
||||
"legendLabRange": "Zakres referencyjny",
|
||||
"legendSymptom": "Linia objawu: najwyższe natężenie każdego zapisanego dnia (od 0 do 10), uśrednione po zapisanych dniach",
|
||||
"symptoms": {
|
||||
"menuTitle": "Objawy",
|
||||
"full": "Naraz można pokazać do {max} objawów. Usuń jeden, aby dodać inny.",
|
||||
"hidden": "ukryty",
|
||||
"unnamed": "Objaw",
|
||||
"daysOne": "w 1 dniu",
|
||||
"daysFew": "średnia z {count} dni",
|
||||
"daysOther": "średnia z {count} dni"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Wydarzenie życiowe",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "W razie potrzeby",
|
||||
"startsOn": "Początek",
|
||||
"endsOn": "Koniec"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Iloraz lub wskaźnik bez jednostki (INR, cholesterol/HDL): wpisz 1 w kolumnie jednostki. Pusta jednostka nadal jest błędem."
|
||||
}
|
||||
}
|
||||
+260
-52
@@ -739,10 +739,10 @@
|
||||
"emptyDescription": "Registre sua primeira medição ou entrada de humor. Blocos e gráficos aparecem automaticamente conforme os dados chegam.",
|
||||
"emptyAddMeasurement": "Adicionar sua primeira medição",
|
||||
"staleHint": "Última leitura há {count} d",
|
||||
"staleHintWeeksOne": "Última leitura há 1 semana",
|
||||
"staleHintWeeksOne": "Última leitura há {count} semana",
|
||||
"staleHintWeeksOther": "Última leitura há {count} semanas",
|
||||
"staleHintWeeksFew": "Última leitura há {count} semanas",
|
||||
"staleHintMonthsOne": "Última leitura há 1 mês",
|
||||
"staleHintMonthsOne": "Última leitura há {count} mês",
|
||||
"staleHintMonthsOther": "Última leitura há {count} meses",
|
||||
"staleHintMonthsFew": "Última leitura há {count} meses",
|
||||
"recentWorkouts": {
|
||||
@@ -819,6 +819,7 @@
|
||||
"typeHeartRateVariability": "Variabilidade da frequência cardíaca",
|
||||
"typeRestingHeartRate": "Frequência cardíaca em repouso",
|
||||
"typeActiveEnergyBurned": "Energia ativa",
|
||||
"typeBasalEnergyBurned": "Energia em repouso",
|
||||
"typeFlightsClimbed": "Andares subidos",
|
||||
"typeWalkingRunningDistance": "Distância caminhada + corrida",
|
||||
"typeVo2Max": "VO2 máx",
|
||||
@@ -969,7 +970,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Insira uma duração como 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Uma entrada não pode durar mais de 24 horas.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Nível de estresse",
|
||||
"typeTrainingReadiness": "Prontidão para treinar",
|
||||
"typeAcuteTrainingLoad": "Carga de treino aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "O que o HealthLog pode acompanhar",
|
||||
@@ -1895,6 +1901,7 @@
|
||||
"intakeHistoryColScheduled": "Programada",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Fonte",
|
||||
"intakeHistoryInjectionSite": "Local da injeção: {site}",
|
||||
"intakeHistoryStatusTaken": "Tomada",
|
||||
"intakeHistoryStatusSkipped": "Pulada",
|
||||
"intakeHistorySortByTaken": "Ordenar por horário da tomada",
|
||||
@@ -2280,7 +2287,7 @@
|
||||
"currentSince": "Plano atual desde {date}",
|
||||
"noTimes": "Sem horários fixos",
|
||||
"manualChip": "Adicionado manualmente",
|
||||
"showErasOne": "Mostrar 1 era anterior",
|
||||
"showErasOne": "Mostrar {count} era anterior",
|
||||
"showErasOther": "Mostrar {count} eras anteriores",
|
||||
"hideEras": "Ocultar eras anteriores",
|
||||
"addEra": "Adicionar era anterior",
|
||||
@@ -2352,7 +2359,7 @@
|
||||
"tableColActions": "Ações",
|
||||
"tableSortBy": "Ordenar por {column}",
|
||||
"tableComplianceDays": "{days} d",
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoseOne": "{count} dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} dias restantes",
|
||||
"cardLowStockRunway": "Estoque: ≈ {days} dias",
|
||||
@@ -3231,7 +3238,7 @@
|
||||
"settingsClustersLabel": "Dados que o Coach vê",
|
||||
"settingsClustersHint": "Escolha quais grupos dos seus dados o Coach pode ler. Mais dados significam respostas mais ricas e um prompt maior.",
|
||||
"settingsClusterNoData": "Ainda sem dados",
|
||||
"sourcesMemberCountOne": "1 métrica",
|
||||
"sourcesMemberCountOne": "{count} métrica",
|
||||
"sourcesMemberCountFew": "{count} métricas",
|
||||
"sourcesMemberCountOther": "{count} métricas",
|
||||
"lookback": {
|
||||
@@ -3331,7 +3338,7 @@
|
||||
"indexing": "Indexando…",
|
||||
"indexingHint": "Um documento ainda está sendo indexado. Você pode enviar quando estiver pronto.",
|
||||
"fencedHint": "Esta conversa roda sem acesso aos seus dados de saúde. O coach lê apenas os documentos anexados.",
|
||||
"documentsAttachedOne": "1 documento anexado",
|
||||
"documentsAttachedOne": "{count} documento anexado",
|
||||
"documentsAttachedOther": "{count} documentos anexados",
|
||||
"railBadge": "Conversa de documento isolada",
|
||||
"uploadError": "Falha no envio. Tente novamente.",
|
||||
@@ -3935,9 +3942,9 @@
|
||||
"learningBody": "Até agora são {count} em {days}. Um pouco mais de histórico e um quadro mais completo entra em foco. Continue registrando, ou deixe um dispositivo conectado preencher.",
|
||||
"learningBodyEmpty": "Assim que algumas leituras chegarem (registradas por você ou sincronizadas de um dispositivo), seu tempo na faixa, média e variabilidade tomarão forma aqui.",
|
||||
"readingsCount": "{count} leituras",
|
||||
"readingsCountOne": "1 leitura",
|
||||
"readingsCountOne": "{count} leitura",
|
||||
"daysCount": "{days} dias",
|
||||
"daysCountOne": "1 dia",
|
||||
"daysCountOne": "{count} dia",
|
||||
"tir": {
|
||||
"title": "Tempo na faixa",
|
||||
"caption": "Parcela das leituras em cada faixa ({low}–{high} {unit} é a meta).",
|
||||
@@ -3989,7 +3996,7 @@
|
||||
"bedtime": "Hora de dormir",
|
||||
"unspecified": "Sem marcação",
|
||||
"readingsShort": "{count} leituras",
|
||||
"readingsShortOne": "1 leitura"
|
||||
"readingsShortOne": "{count} leitura"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4758,7 +4765,8 @@
|
||||
"unitScore": "pontuação",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualidade do sono",
|
||||
@@ -4780,7 +4788,11 @@
|
||||
"cardioLoadExplainer": "Um proxy de carga de treino que acompanha a carga cardiovascular aguda que você acumulou. Compare com os seus próprios dias recentes.",
|
||||
"averageHeartRateExplainer": "Sua frequência cardíaca média ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
|
||||
"maxHeartRateExplainer": "Seu pico de frequência cardíaca ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
|
||||
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente."
|
||||
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente.",
|
||||
"bodyBatteryExplainer": "Sua reserva de energia na escala de 0 a 100 da Garmin, a carga mais alta do dia. Descanso e sono a recarregam, estresse e atividade a consomem.",
|
||||
"trainingReadinessExplainer": "O quanto seu corpo está pronto para um treino pesado, na escala de 0 a 100 da Garmin. O valor chega por uma ponte que você configurou, pois a Garmin não o compartilha diretamente.",
|
||||
"stressLevelExplainer": "O nível médio de estresse do dia segundo a Garmin, numa escala de 0 a 100, lido da variabilidade da sua frequência cardíaca. Mais baixo é mais calmo.",
|
||||
"acuteTrainingLoadExplainer": "A carga de treino aguda da Garmin: a carga ponderada das suas atividades dos últimos sete dias. Chega por uma ponte que você configurou. Compare com as suas próprias semanas recentes."
|
||||
},
|
||||
"loadError": "Não foi possível carregar as suas leituras de recuperação."
|
||||
},
|
||||
@@ -4923,7 +4935,7 @@
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Registros de ECG",
|
||||
"recordingsOne": "1 registro em arquivo.",
|
||||
"recordingsOne": "{count} registro em arquivo.",
|
||||
"recordingsMany": "{count} registros em arquivo.",
|
||||
"latestResult": "Resultado mais recente do dispositivo: {result}.",
|
||||
"cta": "Ver registros"
|
||||
@@ -4937,7 +4949,7 @@
|
||||
"caption": "Média em intervalos de 10 minutos.",
|
||||
"hourlyNote": "Mostrando a média horária deste dia; dias recentes mostram o detalhe completo de 10 minutos.",
|
||||
"empty": "Não há leituras de frequência cardíaca suficientes para este dia mostrar sua forma ainda.",
|
||||
"coverageOne": "Com base em 1 leitura ao longo de {hours}h.",
|
||||
"coverageOne": "Com base em {count} leitura ao longo de {hours}h.",
|
||||
"coverageMany": "Com base em {count} leituras ao longo de {hours}h.",
|
||||
"baseline": "Em repouso",
|
||||
"today": "Hoje",
|
||||
@@ -4971,6 +4983,40 @@
|
||||
"emptyDescription": "Nenhuma pontuação foi registrada nesta faixa ainda.",
|
||||
"usualRange": "Sombreado: sua faixa habitual, {lo} a {hi}.",
|
||||
"seam": "A linha quebra onde o que conta para a pontuação mudou."
|
||||
},
|
||||
"bpTimeOfDay": {
|
||||
"title": "Por período do dia",
|
||||
"description": "Sua pressão arterial média em cada período do dia, no intervalo do gráfico.",
|
||||
"part": {
|
||||
"morning": "Manhã",
|
||||
"midday": "Tarde",
|
||||
"evening": "Noite"
|
||||
},
|
||||
"filterAll": "Dia todo",
|
||||
"filterLabel": "Mostrar no gráfico",
|
||||
"readingsOne": "{count} medição",
|
||||
"readingsFew": "{count} medições",
|
||||
"readingsOther": "{count} medições",
|
||||
"notEnough": "Poucas medições",
|
||||
"pulse": "Pulso {value} bpm",
|
||||
"difference": "Manhã menos noite: {systolic}/{diastolic} mmHg.",
|
||||
"differencePending": "Manhã e noite são comparadas quando cada uma tiver pelo menos {min} medições.",
|
||||
"hours": "Manhã {morning}, tarde {midday}, noite {evening}, no seu fuso horário.",
|
||||
"hourRange": "{start}–{end}",
|
||||
"emptyTitle": "Nenhuma medição completa neste intervalo",
|
||||
"emptyDescription": "Medições com valor sistólico e diastólico aparecem aqui.",
|
||||
"error": "Não foi possível carregar as médias por período do dia.",
|
||||
"chartTitle": {
|
||||
"morning": "Pressão arterial pela manhã",
|
||||
"midday": "Pressão arterial à tarde",
|
||||
"evening": "Pressão arterial à noite"
|
||||
},
|
||||
"statsLabel": "{series}, {part}",
|
||||
"statsPart": {
|
||||
"morning": "pela manhã",
|
||||
"midday": "ao meio-dia",
|
||||
"evening": "à noite"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5232,6 +5278,7 @@
|
||||
"oura": "Oura",
|
||||
"whoop": "WHOOP",
|
||||
"polar": "Polar",
|
||||
"garmin": "Garmin",
|
||||
"fitbit": "Fitbit",
|
||||
"strava": "Strava",
|
||||
"nightscout": "Nightscout",
|
||||
@@ -5239,7 +5286,8 @@
|
||||
},
|
||||
"hints": {
|
||||
"apple-health": "Sincronizado pelo aplicativo iOS ou importado de uma exportação.",
|
||||
"file": "Importado nas Configurações quando terminar aqui."
|
||||
"file": "Importado nas Configurações quando terminar aqui.",
|
||||
"fitbit": "Agora é conectado pelo Google Health."
|
||||
}
|
||||
},
|
||||
"visit": {
|
||||
@@ -5293,6 +5341,7 @@
|
||||
"waiting": "Ainda não conectado.",
|
||||
"openSettings": "Abrir Configurações → Integrações",
|
||||
"noDate": "desconhecido",
|
||||
"throughGoogleHealth": "Os dados do {source} agora chegam pelo {target}: a conexão clássica com o {source} foi desativada. Conecte o {target} com a conta Google em que o seu {source} está conectado, e suas leituras chegam por lá.",
|
||||
"needsCredentials": {
|
||||
"title": "Esta instância ainda não tem um aplicativo {target} registrado.",
|
||||
"detail": "Insira suas próprias credenciais em Configurações → Integrações, então a conexão pode começar."
|
||||
@@ -5393,7 +5442,7 @@
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Primeira tendência após 5 leituras",
|
||||
"remainingOne": "Falta 1 leitura.",
|
||||
"remainingOne": "Falta {count} leitura.",
|
||||
"remainingMany": "Faltam {count} leituras."
|
||||
},
|
||||
"settings": {
|
||||
@@ -5653,7 +5702,11 @@
|
||||
"recovery": "Recuperação",
|
||||
"stress": "Estresse",
|
||||
"sleepDebt": "Déficit de sono",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Nível de estresse",
|
||||
"trainingReadiness": "Prontidão para treinar",
|
||||
"acuteTrainingLoad": "Carga de treino aguda"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5724,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Relógio",
|
||||
@@ -6853,7 +6907,9 @@
|
||||
"summary": "{count} tipos de dados, mais recente {relative}",
|
||||
"quiet": "{count} sem atividade",
|
||||
"workouts": "Treinos",
|
||||
"medicationDoses": "Doses de medicamentos"
|
||||
"medicationDoses": "Doses de medicamentos",
|
||||
"cycleDays": "Dias do ciclo",
|
||||
"cycleDaysCount": "Dias do ciclo ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurado nesta conta sem um cartão de configurações próprio."
|
||||
@@ -7465,11 +7521,6 @@
|
||||
"lastEnteredHere": "Último registro feito aqui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "O Garmin não tem conexão direta aqui. Conecte pelo Apple Health (iOS) ou pelo Google Health Connect (Android). As pontuações próprias da Garmin (Body Battery, Training Load, HRV Status, VO₂ máx.) não são compartilhadas pela Garmin com nenhuma das duas plataformas.",
|
||||
"docsLink": "Ler o guia"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Faixa de referência da glicose",
|
||||
"description": "Com qual faixa as suas leituras de glicose são comparadas.",
|
||||
@@ -7669,7 +7720,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Você sai da sessão e sua conta é bloqueada na hora. Depois de {days} dias, ela é apagada de vez, com todos os dados de saúde, chaves de acesso, sessões e registros de auditoria. Até lá, um administrador deste servidor pode desfazer a exclusão. As cópias nos backups deste servidor expiram depois do período de retenção.",
|
||||
"deleteAccountScheduled": "Sua conta está bloqueada e será apagada em {date}. Saindo da sessão.",
|
||||
"dangerZoneSuccessGrace": "Seu registro de saúde foi excluído. Um administrador deste servidor pode restaurá-lo até {date}.",
|
||||
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo."
|
||||
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Cole uma chave da Garmin Personal API para sincronizar sono, VFC, Body Battery, estresse, passos, peso e atividades do Garmin Connect.",
|
||||
"garminSync": "Sincronizar agora",
|
||||
"garminSyncResult": "{count} registros sincronizados",
|
||||
"garminSyncFailed": "Falha na sincronização",
|
||||
"garminViewData": "Ver sua recuperação",
|
||||
"garminReplaceKey": "Substituir chave",
|
||||
"garminDisconnect": "Desconectar",
|
||||
"garminDisconnectTitle": "Desconectar a Garmin?",
|
||||
"garminDisconnectDescription": "As próximas sincronizações param e a chave é removida deste servidor. Os dados já importados continuam no seu histórico. A chave continua válida na Garmin até você revogá-la lá.",
|
||||
"garminKeyValidUntil": "Chave válida até {date}",
|
||||
"garminKeyExpired": "Sua chave Garmin expirou em {date}. Crie uma nova chave no Garmin Connect e cole-a aqui para retomar a sincronização.",
|
||||
"garminKeyExpiresToday": "Sua chave Garmin expira hoje. Crie uma nova chave no Garmin Connect e cole-a aqui para a sincronização não parar.",
|
||||
"garminKeyExpiring": "Sua chave Garmin expira em {date}. Antes disso, crie uma nova chave no Garmin Connect e cole-a aqui.",
|
||||
"garminKeyExpiryUnknown": "A Garmin não informa quando esta chave expira. Substitua a chave e escolha a validade definida no Garmin Connect, e este cartão avisará você com uma semana de antecedência.",
|
||||
"garminConnected": "Garmin conectada. A primeira sincronização está a caminho.",
|
||||
"garminKeyHowTo": "No Garmin Connect, abra Configurações, depois Personal API, e crie uma chave. A Garmin mostra a chave só uma vez, então copie-a na hora.",
|
||||
"garminKeySettingsLink": "Abrir o Garmin Connect",
|
||||
"garminApiKey": "Chave da Personal API",
|
||||
"garminApiKeyPlaceholder": "Cole a chave",
|
||||
"garminApiKeySavedPlaceholder": "Salva. Cole uma nova chave para substituí-la",
|
||||
"garminKeyValidity": "Validade escolhida",
|
||||
"garminKeyValidityUnknown": "Não sei",
|
||||
"garminKeyValidityDays": "{count} dias",
|
||||
"garminKeyValidityHelp": "A Garmin não informa quando uma chave expira. Com a validade, este cartão avisa você uma semana antes de a chave parar de funcionar.",
|
||||
"garminConnect": "Conectar",
|
||||
"garminUpdate": "Salvar alterações",
|
||||
"garminRegion": "Serviço Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "A Garmin não aceitou esta chave. Verifique se ela foi copiada inteira, pertence ao serviço escolhido e não expirou nem foi revogada.",
|
||||
"rateLimited": "A Garmin está limitando as solicitações agora. Tente de novo em alguns minutos.",
|
||||
"unreachable": "Não foi possível alcançar a Garmin. Tente de novo em alguns minutos.",
|
||||
"invalid": "Essa chave não parece correta. Cole-a de novo, sem espaços.",
|
||||
"generic": "Não deu certo. Tente de novo."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validade da chave",
|
||||
"garminValiditySaved": "Validade salva.",
|
||||
"garminKeyCreatedOn": "Criada em",
|
||||
"garminKeyCreatedOnHelp": "O dia em que você criou a chave no Garmin Connect. Deixe vazio para contar a partir do dia em que a conectou aqui.",
|
||||
"garminUnavailable": "A Garmin não fornece estes dados para sua conta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "resumos diários",
|
||||
"activities": "atividades",
|
||||
"sleep": "sono",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesagens",
|
||||
"cycle": "ciclo menstrual"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administração",
|
||||
@@ -7913,7 +8015,7 @@
|
||||
"colRedeemedBy": "Resgatado por",
|
||||
"colActions": "Ações",
|
||||
"byCreator": "por {by}",
|
||||
"expiresInDaysOne": "em 1 dia",
|
||||
"expiresInDaysOne": "em {count} dia",
|
||||
"expiresInDaysOther": "em {count} dias",
|
||||
"statusActive": "Ativo",
|
||||
"statusExpired": "Expirado",
|
||||
@@ -9924,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Minha menstruação começou neste dia",
|
||||
"endedPeriodOnDate": "Minha menstruação terminou neste dia",
|
||||
"startAbsorbed": "Início da menstruação salvo. Seu início em {absorbed} foi mesclado em {date}.",
|
||||
"startRestored": "Salvo. O início em {dates} está de volta."
|
||||
"startRestored": "Salvo. O início em {dates} está de volta.",
|
||||
"feed": {
|
||||
"title": "Assinatura de calendário",
|
||||
"description": "Suas próximas menstruações como eventos de dia inteiro no app de calendário que você já usa.",
|
||||
"disclosure": "Seu provedor de calendário (Google, iCloud, Outlook) guarda essas datas nos servidores dele. Desativar depois não apaga as cópias que ele já tem.",
|
||||
"turnOn": "Ativar assinatura",
|
||||
"error": "Não foi possível alterar a assinatura de calendário. Tente de novo.",
|
||||
"linkHidden": "Um link que começa com {prefix} está ativo. Ele só aparece uma vez, ao ser criado; renove-o para ter um novo.",
|
||||
"linkLabel": "Link da assinatura",
|
||||
"linkOnce": "Só aparece agora. Cole no seu app de calendário ou renove depois para ter um novo.",
|
||||
"copy": "Copiar link",
|
||||
"copied": "Link copiado.",
|
||||
"copyFailed": "Não deu para copiar. Selecione o link e copie manualmente.",
|
||||
"openInCalendar": "Abrir no app de calendário",
|
||||
"notFetched": "Ainda não buscado.",
|
||||
"lastFetched": "Última busca {when} por {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Calendário da Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "um app de calendário"
|
||||
},
|
||||
"horizon": "Ciclos à frente",
|
||||
"history": "Ciclos anteriores",
|
||||
"historyNone": "Nenhum",
|
||||
"titles": "Títulos dos eventos",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descritivo"
|
||||
},
|
||||
"fertile": "Janela fértil",
|
||||
"fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.",
|
||||
"rotate": "Renovar link",
|
||||
"rotateTitle": "Renovar o link?",
|
||||
"rotateBody": "Calendários com o link antigo deixam de ser atualizados. Você recebe um novo link para adicionar no lugar.",
|
||||
"turnOff": "Desativar",
|
||||
"turnOffTitle": "Desativar a assinatura de calendário?",
|
||||
"turnOffBody": "O link para de funcionar. As cópias que seu provedor de calendário já tem ficam lá até você remover o calendário.",
|
||||
"help": {
|
||||
"title": "Como adicionar ao seu calendário",
|
||||
"google": "Google Agenda: Outras agendas, Do URL, depois cole o link. O Google atualiza mais ou menos a cada 12 a 24 horas.",
|
||||
"apple": "Calendário da Apple: no Mac, Arquivo, Nova Assinatura de Calendário; no iPhone, Ajustes, Calendário, Contas, Adicionar Conta, Outra, Adicionar Calendário Assinado.",
|
||||
"outlook": "Outlook: Adicionar calendário, Assinar da Web, depois cole o link. O Outlook atualiza a cada poucas horas."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Menstruação (estimativa)",
|
||||
"periodLogged": "Menstruação (registrada)",
|
||||
"fertileEstimate": "Janela fértil (estimativa)",
|
||||
"likelyStart": "Início mais provável {date}, {band}",
|
||||
"bandOne": "±{count} dia",
|
||||
"bandFew": "±{count} dias",
|
||||
"bandOther": "±{count} dias"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Ciclos seguintes",
|
||||
"around": "Por volta de {date}",
|
||||
"state": {
|
||||
"LEARNING": "Até haver 3 ciclos registrados, só a próxima menstruação é incluída.",
|
||||
"LATE": "Sem previsão enquanto esta menstruação estiver mais atrasada que o esperado; ela volta assim que você a registrar.",
|
||||
"OFF": "As previsões estão desativadas; só ciclos anteriores são incluídos, se escolhidos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Menstruação provavelmente em breve",
|
||||
@@ -10220,6 +10385,7 @@
|
||||
"unitLockedHint": "Depois que este marcador tiver leituras, sua unidade permanece como está. Caso contrário, os valores armazenados seriam exibidos em uma nova unidade sem serem convertidos.",
|
||||
"panel": "Painel",
|
||||
"panelPlaceholder": "ex.: Lipídios",
|
||||
"panelHint": "Escolha uma sugestão ou digite o seu. Os painéis do catálogo são reconhecidos em todos os idiomas.",
|
||||
"lowerBound": "Referência baixa",
|
||||
"upperBound": "Referência alta",
|
||||
"lowerPlaceholder": "Limite inferior",
|
||||
@@ -10393,14 +10559,33 @@
|
||||
"fileTooLarge": "O arquivo é grande demais (máx. 12 MB).",
|
||||
"fileType": "Envie um JPEG, PNG, WebP ou PDF.",
|
||||
"extractFailed": "Não foi possível ler o laudo. Tente uma foto mais nítida.",
|
||||
"pdfNeedsAnthropic": "A digitalização de PDF precisa de um provedor de visão Claude; envie uma foto em vez disso.",
|
||||
"pdfRendererUnavailable": "O processador deste servidor não consegue renderizar páginas de PDF, então um laudo digitalizado só pode ser lido por um provedor de IA que aceite arquivos PDF diretamente. Envie uma foto em vez disso.",
|
||||
"refTextLabel": "Faixa como impressa",
|
||||
"refTextPlaceholder": "ex.: 3,5 - 5,0",
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora."
|
||||
"aiUnavailable": "Digitalizar um laudo com IA não está disponível aqui agora.",
|
||||
"errorUnitMismatchNoUnit": "Este marcador é acompanhado sem unidade. Marque Sem unidade se o laudo foi lido errado, ou desmarque esta leitura.",
|
||||
"unitDiffersBadgeNoUnit": "Acompanhado sem unidade; este laudo diz {unit}"
|
||||
},
|
||||
"loadMoreReadings": "Carregar mais leituras",
|
||||
"documentsLink": "Documentos de laudos laboratoriais",
|
||||
"notLinkedYet": "Ainda não vinculado a um biomarcador",
|
||||
"groups": {
|
||||
"other": "Outros",
|
||||
"allInRange": "Tudo na faixa",
|
||||
"outsideCountOne": "{count} de {total} fora da faixa",
|
||||
"outsideCountFew": "{count} de {total} fora da faixa",
|
||||
"outsideCountOther": "{count} de {total} fora da faixa",
|
||||
"heading": "Categorias",
|
||||
"description": "Agrupa os valores por painel, como Lipídios ou Fígado, com um status por grupo.",
|
||||
"label": "Agrupamento",
|
||||
"none": "Nenhum",
|
||||
"category": "Por categoria",
|
||||
"openLabel": "Abertas ao carregar",
|
||||
"openExpanded": "Todas",
|
||||
"openFlagged": "Só fora da faixa",
|
||||
"openCollapsed": "Nenhuma",
|
||||
"orderWithinGroups": "A ordem vale dentro de cada categoria."
|
||||
},
|
||||
"sourceRange": {
|
||||
"fromReport": "Faixa do laudo {range}",
|
||||
"catalogDiffers": "sua faixa {range}",
|
||||
@@ -10447,6 +10632,7 @@
|
||||
"convertedOther": "{count} leituras, convertidas de {unit} para {targetUnit}",
|
||||
"refused": "{unit} não pode ser convertida para {targetUnit}.",
|
||||
"aliasesAdded": "Estes nomes levarão ao biomarcador mantido:",
|
||||
"adoptsPanel": "O biomarcador mantido assume o painel {panel}.",
|
||||
"blocked": "Nada pode ser mesclado enquanto uma unidade não puder ser convertida. Deixe esse biomarcador fora ou mantenha outro.",
|
||||
"noUndo": "Uma mesclagem não pode ser desfeita.",
|
||||
"doneToastOne": "Mesclado em {name}, {count} leitura movida",
|
||||
@@ -10474,6 +10660,10 @@
|
||||
"stale": "O resultado mais recente é de {date}, há mais de um ano. A linha é mostrada até essa data, sem projeção."
|
||||
},
|
||||
"interval": "Intervalo de 95 % da mudança anual: {lo} a {hi} {unit}."
|
||||
},
|
||||
"unit": {
|
||||
"none": "Sem unidade",
|
||||
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10845,7 +11035,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"title": "Seu histórico de doenças",
|
||||
"countOne": "Você registrou 1 episódio neste período.",
|
||||
"countOne": "Você registrou {count} episódio neste período.",
|
||||
"countOther": "Você registrou {count} episódios neste período.",
|
||||
"typicalGap": "Lacuna típica de recuperação: {days} dias entre se sentir melhor e seus números se estabilizarem.",
|
||||
"typicalGapWithDriver": "Lacuna típica de recuperação: {days} dias; você tende a se sentir melhor antes de seu {vital} se estabilizar.",
|
||||
@@ -10858,7 +11048,7 @@
|
||||
"resolved": "Resolvidos"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 surto",
|
||||
"countOne": "{count} surto",
|
||||
"countOther": "{count} surtos"
|
||||
},
|
||||
"timeline": {
|
||||
@@ -10990,13 +11180,13 @@
|
||||
"toggleShow": "Mostrar o que o Coach examinou",
|
||||
"toggleHide": "Ocultar o que o Coach examinou",
|
||||
"announceDone": "Concluído: {label}",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "{count} leituras",
|
||||
"readingsOther": "{count} leituras",
|
||||
"rowsOne": "1 linha",
|
||||
"rowsOne": "{count} linha",
|
||||
"rowsFew": "{count} linhas",
|
||||
"rowsOther": "{count} linhas",
|
||||
"metricsOne": "1 métrica",
|
||||
"metricsOne": "{count} métrica",
|
||||
"metricsFew": "{count} métricas",
|
||||
"metricsOther": "{count} métricas",
|
||||
"reason": {
|
||||
@@ -11100,7 +11290,7 @@
|
||||
"entry": "{domain}, {window}: {detail}",
|
||||
"entryNoWindow": "{domain}: {detail}",
|
||||
"reused": "de uma resposta anterior",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "{count} leituras",
|
||||
"readingsOther": "{count} leituras",
|
||||
"aggregation": {
|
||||
@@ -11903,7 +12093,8 @@
|
||||
"unsupportedType": "Este tipo de arquivo não é suportado.",
|
||||
"purged": "Este documento foi removido permanentemente e não pode ser restaurado.",
|
||||
"duplicateExists": "Um documento idêntico já está armazenado.",
|
||||
"rateLimited": "Envios demais agora. Tente novamente em alguns minutos.",
|
||||
"uploadBusy": "O servidor estava ocupado com outros envios. Tente novamente agora.",
|
||||
"rateLimited": "Você atingiu o limite de envios desta hora. Tente novamente mais tarde.",
|
||||
"generic": "Falha no envio. Tente novamente."
|
||||
},
|
||||
"toast": {
|
||||
@@ -11954,7 +12145,7 @@
|
||||
"decrypt-error": "O arquivo armazenado não pôde ser aberto para leitura.",
|
||||
"raster-failed": "As páginas do PDF não puderam ser renderizadas para leitura. Formatos de compressão de fax digitalizado são uma causa conhecida.",
|
||||
"provider-error": "O provedor de IA não conseguiu ler este documento na última tentativa.",
|
||||
"pdf-needs-anthropic": "O provedor de IA configurado não consegue ler arquivos PDF. Um provedor Claude ou OCR local consegue.",
|
||||
"pdf-needs-anthropic": "O processador deste servidor não consegue renderizar páginas de PDF e este PDF não tem camada de texto. Um provedor de IA que aceite arquivos PDF diretamente consegue ler, ou envie fotos das páginas.",
|
||||
"empty-transcription": "O provedor de IA não retornou texto para este documento.",
|
||||
"resource-limit": "Este PDF é grande ou complexo demais para ser lido aqui. O arquivo em si está bem e continua abrindo."
|
||||
}
|
||||
@@ -12037,7 +12228,7 @@
|
||||
"errorRateLimitedWaitFew": "O limite horário de leitura por IA foi atingido. Tente novamente em cerca de {count} minutos.",
|
||||
"errorRateLimitedWaitOther": "O limite horário de leitura por IA foi atingido. Tente novamente em cerca de {count} minutos.",
|
||||
"errorBudget": "Seu orçamento de IA para hoje foi esgotado.",
|
||||
"errorPdfNeedsVision": "Digitalizar PDFs precisa de um provedor de visão Claude.",
|
||||
"errorPdfRendererUnavailable": "O processador deste servidor não consegue renderizar páginas de PDF, então um PDF digitalizado só pode ser lido por um provedor de IA que aceite arquivos PDF diretamente, ou como fotos das páginas.",
|
||||
"errorFileType": "Este tipo de arquivo não pode ser lido.",
|
||||
"errorNotIndexed": "Leia o documento primeiro, depois extraia.",
|
||||
"errorOutputLimit": "O provedor de IA atingiu o limite de saída antes de devolver um único valor. Tente um modelo que permita respostas mais longas.",
|
||||
@@ -12057,7 +12248,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "A IA leu seu documento.",
|
||||
"readStagedOne": "A IA leu seu documento. 1 valor laboratorial está pronto para revisão.",
|
||||
"readStagedOne": "A IA leu seu documento. {count} valor laboratorial está pronto para revisão.",
|
||||
"readStagedFew": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.",
|
||||
"readStagedOther": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.",
|
||||
"read": "Ler com IA",
|
||||
@@ -12089,12 +12280,12 @@
|
||||
"fieldValueText": "Resultado (texto)",
|
||||
"fieldUnit": "Unidade",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valor ainda precisa de verificação e permanece pendente.",
|
||||
"unverifiedNoteOne": "{count} valor ainda precisa de verificação e permanece pendente.",
|
||||
"unverifiedNoteFew": "{count} valores ainda precisam de verificação e permanecem pendentes.",
|
||||
"unverifiedNoteOther": "{count} valores ainda precisam de verificação e permanecem pendentes.",
|
||||
"finish": "Concluir revisão",
|
||||
"confirmFailed": "A revisão não pôde ser salva. Tente novamente.",
|
||||
"savedToastOne": "1 valor salvo",
|
||||
"savedToastOne": "{count} valor salvo",
|
||||
"savedToastFew": "{count} valores salvos",
|
||||
"savedToastOther": "{count} valores salvos",
|
||||
"nothingSavedToast": "Revisão salva. Nenhum valor foi adicionado.",
|
||||
@@ -13054,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} mesclou biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} alterou o que um suplemento contém",
|
||||
"auditCCreate": "{name} respondeu às perguntas AUDIT-C",
|
||||
"auditCDelete": "{name} removeu uma entrada AUDIT-C"
|
||||
"auditCDelete": "{name} removeu uma entrada AUDIT-C",
|
||||
"cycleFeedCreate": "{name} ativou a assinatura de calendário",
|
||||
"cycleFeedUpdate": "{name} alterou a assinatura de calendário",
|
||||
"cycleFeedRotate": "{name} renovou o link da assinatura de calendário",
|
||||
"cycleFeedRevoke": "{name} desativou a assinatura de calendário"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -13231,7 +13426,9 @@
|
||||
"specialty": "Desde a sua última consulta de {specialty}, em {date}",
|
||||
"kind": "Desde a sua última consulta do mesmo tipo ({kind}), em {date}",
|
||||
"default": "Os últimos {days} dias",
|
||||
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo."
|
||||
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
|
||||
"capped": "Os últimos dois anos",
|
||||
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
@@ -13408,7 +13605,7 @@
|
||||
"deleteBody": "“{symptom}” e todas as suas entradas serão excluídos. Para manter o histórico, oculte-o em vez disso.",
|
||||
"deleteEventTitle": "Excluir entrada?",
|
||||
"deleteEventBody": "Esta entrada será excluída.",
|
||||
"hiddenOne": "Ocultos (1)",
|
||||
"hiddenOne": "Ocultos ({count})",
|
||||
"hiddenFew": "Ocultos ({count})",
|
||||
"hiddenOther": "Ocultos ({count})",
|
||||
"recent": "Últimos 30 dias",
|
||||
@@ -13475,14 +13672,14 @@
|
||||
"valuesAllFew": "Todos os {count} valores",
|
||||
"valuesAllOther": "Todos os {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"countEntriesOne": "1 entrada",
|
||||
"countEntriesOne": "{count} entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
"meanOfReadingsOne": "Média de 1 leitura",
|
||||
"meanOfReadingsOne": "Média de {count} leitura",
|
||||
"meanOfReadingsFew": "Média de {count} leituras",
|
||||
"meanOfReadingsOther": "Média de {count} leituras",
|
||||
"sinceLastVisit": {
|
||||
"countOne": "1 entrada",
|
||||
"countOne": "{count} entrada",
|
||||
"countFew": "{count} entradas",
|
||||
"countOther": "{count} entradas",
|
||||
"allOne": "Mostrar tudo",
|
||||
@@ -13671,7 +13868,7 @@
|
||||
},
|
||||
"noValue": "sem valor",
|
||||
"bloodPressure": "Pressão arterial",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "média de {count} leituras",
|
||||
"readingsOther": "média de {count} leituras"
|
||||
},
|
||||
@@ -13704,7 +13901,7 @@
|
||||
"empty": "Nenhuma entrada de {from} a {to}",
|
||||
"emptySingle": "Nenhuma entrada em {period}",
|
||||
"ended": "{label} terminou",
|
||||
"afterDaysOne": "após 1 dia",
|
||||
"afterDaysOne": "após {count} dia",
|
||||
"afterDaysFew": "após {count} dias",
|
||||
"afterDaysOther": "após {count} dias",
|
||||
"notable": {
|
||||
@@ -13728,7 +13925,7 @@
|
||||
"apply": "Aplicar"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "1 valor laboratorial",
|
||||
"labValuesOne": "{count} valor laboratorial",
|
||||
"labValuesFew": "{count} valores laboratoriais",
|
||||
"labValuesOther": "{count} valores laboratoriais"
|
||||
},
|
||||
@@ -13747,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valor de exame, como foi medido",
|
||||
"legendLabOutside": "Fora da faixa de referência",
|
||||
"legendLabRange": "Faixa de referência"
|
||||
"legendLabRange": "Faixa de referência",
|
||||
"legendSymptom": "Linha do sintoma: a maior intensidade de cada dia registrado (de 0 a 10), em média sobre os dias registrados",
|
||||
"symptoms": {
|
||||
"menuTitle": "Sintomas",
|
||||
"full": "Até {max} sintomas ao mesmo tempo. Remova um para adicionar outro.",
|
||||
"hidden": "oculto",
|
||||
"unnamed": "Sintoma",
|
||||
"daysOne": "em {count} dia",
|
||||
"daysFew": "média de {count} dias",
|
||||
"daysOther": "média de {count} dias"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento de vida",
|
||||
@@ -14093,6 +14300,7 @@
|
||||
"asNeeded": "Conforme necessário",
|
||||
"startsOn": "Início",
|
||||
"endsOn": "Fim"
|
||||
}
|
||||
},
|
||||
"unitlessHint": "Uma razão ou um índice sem unidade (INR, colesterol/HDL): escreva 1 na coluna da unidade. Uma unidade vazia continua sendo um erro."
|
||||
}
|
||||
}
|
||||
+14306
File diff suppressed because it is too large.
Load diff
+7
-6
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.46.5",
|
||||
"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",
|
||||
|
||||
Generated
+650
-620
File diff suppressed because it is too large.
Load diff
+15
-17
@@ -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,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$;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -8296,6 +8334,76 @@ model CycleProfile {
|
||||
@@map("cycle_profiles")
|
||||
}
|
||||
|
||||
/// #1350 — how the feed titles its events. DISCREET shows a dot and names the
|
||||
/// calendar "HealthLog"; DESCRIPTIVE names the period and the calendar.
|
||||
enum CycleCalendarFeedTitleMode {
|
||||
DISCREET
|
||||
DESCRIPTIVE
|
||||
|
||||
@@map("cycle_calendar_feed_title_mode")
|
||||
}
|
||||
|
||||
/// #1350 — a subscribable calendar feed of one record's cycle forecast.
|
||||
///
|
||||
/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is
|
||||
/// stored (`hashToken`, the same key as `ApiToken.tokenHash`); the raw token is
|
||||
/// returned once at mint and is unrecoverable afterwards. The feed is computed
|
||||
/// live on every fetch from the record's own rows, so nothing about the
|
||||
/// forecast is stored here.
|
||||
///
|
||||
/// At most one live feed per record: a partial unique index on `user_id WHERE
|
||||
/// revoked_at IS NULL`, written in the migration because Prisma cannot express
|
||||
/// it. Revocation stamps `revokedAt` and never deletes, so the status read can
|
||||
/// say when a link stopped working. Not in the backup: a restore would bring
|
||||
/// back a link the owner revoked (see `backup-plan.ts`).
|
||||
model CycleCalendarFeed {
|
||||
id String @id @default(cuid())
|
||||
/// The record whose forecast the feed serves.
|
||||
userId String @map("user_id")
|
||||
/// Who minted it: the owner, or the guardian of a managed profile. A
|
||||
/// guardian's feed is re-checked against their live MANAGE grant and the
|
||||
/// managed-profile marker on every fetch.
|
||||
createdById String @map("created_by_id")
|
||||
|
||||
/// HMAC-SHA256 of the raw `hlf_` token.
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// The first 8 characters of the raw token, shown in settings so the owner
|
||||
/// can tell which link is live. Not enough to rebuild it.
|
||||
tokenPrefix String @map("token_prefix")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
/// Fetch statistics, written fire-and-forget by the feed route and read by
|
||||
/// the status endpoint ("last fetched 3 h ago by Google Calendar").
|
||||
lastFetchedAt DateTime? @map("last_fetched_at")
|
||||
fetchCount Int @default(0) @map("fetch_count")
|
||||
/// The last fetcher's User-Agent, truncated to 80 characters.
|
||||
lastFetcherUa String? @map("last_fetcher_ua")
|
||||
|
||||
/// SHA-256 of the last served body without its timestamps. When a fetch
|
||||
/// builds a different body, `contentChangedAt` moves; both drive DTSTAMP,
|
||||
/// LAST-MODIFIED and the ETag.
|
||||
bodyHash String? @map("body_hash")
|
||||
contentChangedAt DateTime @default(now()) @map("content_changed_at")
|
||||
|
||||
/// Forecast cycles ahead (1..6). The feed caps itself further once the
|
||||
/// band grows past ±10 days or half a cycle.
|
||||
horizonCycles Int @default(3) @map("horizon_cycles")
|
||||
/// Observed cycles included as history (0..12).
|
||||
historyCycles Int @default(0) @map("history_cycles")
|
||||
/// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only.
|
||||
includeFertile Boolean @default(false) @map("include_fertile")
|
||||
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
|
||||
|
||||
user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([createdById])
|
||||
@@map("cycle_calendar_feeds")
|
||||
}
|
||||
|
||||
/// One row per menstrual cycle (a period-start anchor + derived span).
|
||||
/// `isPredicted` rows are forward-minted forecasts; observed rows are
|
||||
/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the
|
||||
|
||||
@@ -1,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
@@ -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.5";
|
||||
/* @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/*`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -308,6 +308,7 @@ async function main() {
|
||||
"nightscoutTokenEncrypted",
|
||||
"dawarichUrlEncrypted",
|
||||
"dawarichApiKeyEncrypted",
|
||||
"garminApiKeyEncrypted",
|
||||
"polarAccessTokenEncrypted",
|
||||
"polarUserIdEncrypted",
|
||||
"polarClientIdEncrypted",
|
||||
|
||||
+17
-6
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94362,
|
||||
"total": 94047,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
"components/cycle/log-day-sheet.tsx": 1639,
|
||||
"components/insights/coach-panel/chat-bubble.tsx": 822,
|
||||
"components/insights/coach-panel/coach-conversation.tsx": 1286,
|
||||
"components/insights/coach-panel/coach-conversation.tsx": 1278,
|
||||
"components/insights/coach-panel/use-coach.ts": 1486,
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
@@ -17,19 +17,19 @@
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
"components/records/life-circumstances-manager.tsx": 1080,
|
||||
"components/records/life-circumstances-manager.tsx": 828,
|
||||
"components/timeline/timeline-chart.tsx": 1093,
|
||||
"components/timeline/timeline-geometry.ts": 1288,
|
||||
"lib/ai/anthropic-client.ts": 804,
|
||||
"lib/ai/coach/about-me.ts": 1012,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1079,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1091,
|
||||
"lib/ai/coach/persistence.ts": 1218,
|
||||
"lib/ai/coach/results/metric-table-tool.ts": 1102,
|
||||
"lib/ai/coach/snapshot.ts": 1346,
|
||||
"lib/ai/coach/stream-events.ts": 817,
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/coach/stream-events.ts": 811,
|
||||
"lib/ai/coach/types.ts": 1008,
|
||||
"lib/ai/codex-client.ts": 907,
|
||||
"lib/ai/prompts/insight-generator.ts": 1110,
|
||||
"lib/ai/prompts/insight-generator.ts": 1115,
|
||||
"lib/ai/provider.ts": 1628,
|
||||
"lib/analytics/score/reader.ts": 998,
|
||||
"lib/analytics/sleep-night.ts": 1345,
|
||||
@@ -39,10 +39,10 @@
|
||||
"lib/daily/digest.ts": 1317,
|
||||
"lib/daily/load-digest.ts": 1000,
|
||||
"lib/daily/today-overview.ts": 838,
|
||||
"lib/dashboard-layout.ts": 971,
|
||||
"lib/dashboard-layout.ts": 976,
|
||||
"lib/dashboard/snapshot.ts": 1550,
|
||||
"lib/day/records.ts": 1174,
|
||||
"lib/export/backup-plan.ts": 853,
|
||||
"lib/export/backup-plan.ts": 838,
|
||||
"lib/export/coach-backup.ts": 1070,
|
||||
"lib/export/profile-backup.ts": 1019,
|
||||
"lib/fitbit/client.ts": 1262,
|
||||
@@ -62,15 +62,15 @@
|
||||
"lib/insights/correlations.ts": 1026,
|
||||
"lib/insights/features.ts": 1875,
|
||||
"lib/insights/mood-aggregates.ts": 1053,
|
||||
"lib/integrations/status.ts": 1445,
|
||||
"lib/integrations/status.ts": 1448,
|
||||
"lib/jobs/coach-nudge.ts": 1084,
|
||||
"lib/jobs/insight-pregenerate.ts": 1189,
|
||||
"lib/jobs/medication-intake-import.ts": 819,
|
||||
"lib/jobs/offhost-backup.ts": 1094,
|
||||
"lib/jobs/reaction-line.ts": 803,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1467,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"lib/mcp/record-search.ts": 1397,
|
||||
"lib/mcp/rich-reads.ts": 1471,
|
||||
"lib/measurements/dense-intraday-retention.ts": 1016,
|
||||
"lib/measurements/import-apple-health-export.ts": 1722,
|
||||
"lib/medications/scheduling/cadence.ts": 976,
|
||||
@@ -86,6 +86,6 @@
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
"proxy.ts": 870
|
||||
"proxy.ts": 846
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 345,
|
||||
"total": 111929,
|
||||
"count": 344,
|
||||
"total": 111520,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 260,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 226,
|
||||
@@ -21,10 +20,10 @@
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
|
||||
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 476,
|
||||
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280,
|
||||
"app/api/labs/route.ts :: postLabResult": 242,
|
||||
"app/api/labs/route.ts :: postLabResult": 241,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST": 250,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
@@ -45,7 +44,7 @@
|
||||
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
|
||||
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
|
||||
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
|
||||
"app/insights/page-client.tsx :: InsightsPageClient": 376,
|
||||
"app/insights/page-client.tsx :: InsightsPageClient": 375,
|
||||
"app/labs/page.tsx :: LabsPage": 228,
|
||||
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
|
||||
"app/notifications/page.tsx :: NotificationsPage": 327,
|
||||
@@ -81,7 +80,7 @@
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 408,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 407,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
|
||||
"components/daily/today-hero.tsx :: TodayHero": 488,
|
||||
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
|
||||
@@ -93,7 +92,7 @@
|
||||
"components/documents/document-card.tsx :: DocumentCard": 354,
|
||||
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325,
|
||||
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
|
||||
"components/documents/document-facts-review.tsx :: FactRow": 262,
|
||||
"components/documents/document-facts-review.tsx :: FactRow": 261,
|
||||
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
|
||||
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
|
||||
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
|
||||
@@ -103,7 +102,7 @@
|
||||
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
|
||||
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
|
||||
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
|
||||
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1011,
|
||||
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
|
||||
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
|
||||
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
|
||||
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
|
||||
@@ -130,16 +129,15 @@
|
||||
"components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354,
|
||||
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
|
||||
"components/insights/trends-row.tsx :: TrendsRow": 215,
|
||||
"components/labs/biomarker-form.tsx :: BiomarkerForm": 244,
|
||||
"components/labs/biomarker-form.tsx :: BiomarkerForm": 229,
|
||||
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
|
||||
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250,
|
||||
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239,
|
||||
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
|
||||
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
|
||||
"components/labs/lab-form.tsx :: LabForm": 473,
|
||||
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
|
||||
"components/labs/lab-list.tsx :: LabList": 308,
|
||||
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 518,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 262,
|
||||
"components/layout/capture-picker.tsx :: CapturePicker": 235,
|
||||
@@ -156,7 +154,7 @@
|
||||
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
|
||||
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
|
||||
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
|
||||
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 427,
|
||||
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
|
||||
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
|
||||
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
|
||||
"components/medications/medication-card.tsx :: MedicationCard": 413,
|
||||
@@ -180,14 +178,14 @@
|
||||
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
|
||||
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
|
||||
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
|
||||
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425,
|
||||
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
|
||||
"components/onboarding/tour.tsx :: OnboardingTour": 544,
|
||||
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
|
||||
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
|
||||
"components/records/allergy-form.tsx :: AllergyForm": 225,
|
||||
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
|
||||
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
|
||||
"components/records/life-circumstances-manager.tsx :: SectionBody": 482,
|
||||
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
|
||||
"components/settings/about-section.tsx :: AboutSection": 236,
|
||||
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
|
||||
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
|
||||
@@ -205,9 +203,9 @@
|
||||
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
|
||||
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
|
||||
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
|
||||
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 460,
|
||||
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
|
||||
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
|
||||
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
|
||||
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227,
|
||||
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
|
||||
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
|
||||
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
|
||||
@@ -243,11 +241,11 @@
|
||||
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
|
||||
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
|
||||
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
|
||||
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
|
||||
"lib/ai/coach/turn/model.ts :: runTurnModel": 538,
|
||||
"lib/ai/coach/turn/model.ts :: runTurnModel": 534,
|
||||
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
|
||||
"lib/ai/coach/turn/reply-guards.ts :: guardReply": 331,
|
||||
"lib/ai/codex-client.ts :: parseResponse": 302,
|
||||
@@ -286,21 +284,21 @@
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
|
||||
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 309,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 307,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 229,
|
||||
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217,
|
||||
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
|
||||
"lib/insights/features.ts :: extractFeatures": 796,
|
||||
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
|
||||
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
|
||||
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
|
||||
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
|
||||
"lib/integrations/status.ts :: recordSyncFailure": 292,
|
||||
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
|
||||
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
|
||||
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
|
||||
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
|
||||
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
|
||||
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
|
||||
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238,
|
||||
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
|
||||
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
|
||||
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
|
||||
@@ -315,7 +313,7 @@
|
||||
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592,
|
||||
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 587,
|
||||
"lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324,
|
||||
"lib/labs/biomarker-merge.ts :: runMerge": 254,
|
||||
"lib/labs/biomarker-merge.ts :: runMerge": 232,
|
||||
"lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278,
|
||||
"lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263,
|
||||
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539,
|
||||
@@ -335,8 +333,8 @@
|
||||
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
@@ -347,6 +345,6 @@
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 498
|
||||
"proxy.ts :: proxy": 471
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 523,
|
||||
"total": 5483,
|
||||
"total": 5476,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -360,7 +360,7 @@
|
||||
"lib/documents/__tests__/assist-kinds.test.ts": 1,
|
||||
"lib/documents/__tests__/commit.test.ts": 9,
|
||||
"lib/documents/__tests__/fenced-chat-pipeline.test.ts": 6,
|
||||
"lib/documents/__tests__/index-document.test.ts": 22,
|
||||
"lib/documents/__tests__/index-document.test.ts": 15,
|
||||
"lib/documents/__tests__/index-outcome-record.test.ts": 14,
|
||||
"lib/documents/__tests__/prepare-vision-raster-failure.test.ts": 6,
|
||||
"lib/documents/__tests__/provider-order.test.ts": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2895,
|
||||
"total": 9226,
|
||||
"count": 2892,
|
||||
"total": 9220,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -824,7 +824,7 @@
|
||||
"components/documents/document-ai-transport.ts": 2,
|
||||
"components/documents/document-bulk-bar.tsx": 1,
|
||||
"components/documents/document-card.tsx": 2,
|
||||
"components/documents/document-facts-review.tsx": 2,
|
||||
"components/documents/document-facts-review.tsx": 1,
|
||||
"components/documents/document-summary-block.tsx": 2,
|
||||
"components/documents/use-content-index.ts": 1,
|
||||
"components/documents/use-document-assist.ts": 1,
|
||||
@@ -917,7 +917,7 @@
|
||||
"components/insights/coach-panel/chat-bubble.tsx": 25,
|
||||
"components/insights/coach-panel/coach-conversation.tsx": 40,
|
||||
"components/insights/coach-panel/coach-drawer-body.tsx": 6,
|
||||
"components/insights/coach-panel/coach-drawer.tsx": 14,
|
||||
"components/insights/coach-panel/coach-drawer.tsx": 13,
|
||||
"components/insights/coach-panel/coach-hero.tsx": 2,
|
||||
"components/insights/coach-panel/coach-input.tsx": 12,
|
||||
"components/insights/coach-panel/coach-model-picker.tsx": 1,
|
||||
@@ -958,7 +958,6 @@
|
||||
"components/insights/derived/coach-read-strip.tsx": 2,
|
||||
"components/insights/derived/composite-score-anatomy.tsx": 6,
|
||||
"components/insights/derived/coverage-meter.tsx": 1,
|
||||
"components/insights/derived/index.ts": 1,
|
||||
"components/insights/derived/provenance-explainer.tsx": 2,
|
||||
"components/insights/derived/ring-hues.ts": 2,
|
||||
"components/insights/derived/score-anatomy-view.tsx": 3,
|
||||
@@ -1073,7 +1072,7 @@
|
||||
"components/labs/lab-biomarker-detail.tsx": 13,
|
||||
"components/labs/lab-form.tsx": 6,
|
||||
"components/labs/lab-history-list.tsx": 4,
|
||||
"components/labs/lab-list.tsx": 6,
|
||||
"components/labs/lab-list.tsx": 5,
|
||||
"components/labs/lab-reference-range-bar.tsx": 1,
|
||||
"components/labs/lab-trend-card.tsx": 1,
|
||||
"components/labs/lab-trend-sparkline.tsx": 1,
|
||||
@@ -1379,7 +1378,6 @@
|
||||
"components/settings/integrations/__tests__/apple-health-card.test.tsx": 2,
|
||||
"components/settings/integrations/apple-health-card.tsx": 1,
|
||||
"components/settings/integrations/connections-panel.tsx": 5,
|
||||
"components/settings/integrations/garmin-info-note.tsx": 1,
|
||||
"components/settings/integrations/notification-channels-panel.tsx": 1,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 2,
|
||||
"components/settings/integrations/shared.tsx": 3,
|
||||
@@ -1960,7 +1958,6 @@
|
||||
"lib/day/values.ts": 1,
|
||||
"lib/day/visit-preparation.ts": 1,
|
||||
"lib/day/wire-schemas.ts": 1,
|
||||
"lib/db-compat.ts": 1,
|
||||
"lib/devices/__tests__/revoke.test.ts": 1,
|
||||
"lib/devices/revoke.ts": 1,
|
||||
"lib/doctor-report-helpers.ts": 1,
|
||||
@@ -2895,7 +2892,7 @@
|
||||
"lib/withings/sync.ts": 6,
|
||||
"lib/withings/webhook-handler.ts": 3,
|
||||
"lib/workouts/list-read.ts": 1,
|
||||
"proxy.ts": 29,
|
||||
"proxy.ts": 27,
|
||||
"types/analytics.ts": 8,
|
||||
"types/backups.ts": 1
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* The pnpm store held two canvas versions: the one in package.json and an
|
||||
* older one that a second, older pdfjs-dist pulled in (gone since #1277).
|
||||
* The runner stage picked the musl binary with `find | head -1`, got the
|
||||
* The runner stage picked the platform binary with `find | head -1`, got the
|
||||
* old one, and copied it into the new loader's directory, where the loader
|
||||
* looks first. PDF rendering still worked, so nothing looked wrong;
|
||||
* `loadImage` called an `Image.decode` the old binary lacks, and every
|
||||
@@ -43,7 +43,7 @@ describe("canvas binary hoist", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hoists the loader and the musl binary of that version only", () => {
|
||||
it("hoists the loader and the glibc binary of that version only", () => {
|
||||
const runner = stage("runner");
|
||||
expect(runner).toContain(
|
||||
"COPY --from=builder /app/.canvas-version /tmp/.canvas-version",
|
||||
@@ -51,12 +51,44 @@ describe("canvas binary hoist", () => {
|
||||
expect(runner).toContain(
|
||||
'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/',
|
||||
);
|
||||
expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/");
|
||||
// The image is Debian: the glibc (`-gnu`) build is the one that runs,
|
||||
// and the musl build, which needs ARMv8.2 on arm64 (#1311), is not.
|
||||
expect(runner).toContain("canvas-linux-*-gnu@${CANVAS_VERSION}/");
|
||||
expect(runner).toContain("-name 'skia.linux-*-gnu.node'");
|
||||
expect(runner).not.toMatch(/-musl/);
|
||||
// A version-blind pick is the defect: any `canvas…@*` glob decides by
|
||||
// readdir order which of the store's copies wins.
|
||||
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
|
||||
});
|
||||
|
||||
it("installs and runs on the same Debian glibc image", () => {
|
||||
const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? [];
|
||||
expect(froms).toEqual([
|
||||
expect.stringMatching(
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS deps$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS prisma-cli$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64} AS pgclient$/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS runner$/,
|
||||
),
|
||||
]);
|
||||
// The native packages pnpm picked in the builder must load in the
|
||||
// runner: same module ABI, and a glibc no older than the builder's.
|
||||
expect(stage("builder")).toContain("> /app/.native-abi");
|
||||
expect(stage("runner")).toContain(
|
||||
'[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI',
|
||||
);
|
||||
expect(stage("runner")).toContain("is older than the builder's");
|
||||
});
|
||||
|
||||
it("fails the build when the copied binary is another version", () => {
|
||||
const runner = stage("runner");
|
||||
expect(runner).toMatch(
|
||||
|
||||
@@ -74,9 +74,11 @@ function entrypointScripts(): string[] {
|
||||
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
|
||||
return [
|
||||
...new Set(
|
||||
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
// A line that starts with the call, or a command substitution that
|
||||
// makes it (`url="$(node scripts/db-app-role.mjs …)"`).
|
||||
[
|
||||
...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm),
|
||||
].map((m) => m[1]),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
@@ -126,6 +128,7 @@ describe("scripts the entrypoint runs ship in the image", () => {
|
||||
it("finds the boot scripts it is meant to check", () => {
|
||||
// An empty match would agree with any Dockerfile.
|
||||
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
|
||||
expect(entrypointScripts()).toContain("scripts/db-app-role.mjs");
|
||||
});
|
||||
|
||||
it("copies every boot script and its local imports into the runner stage", () => {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* #1350 — the calendar feed carries dates and a title, and nothing else.
|
||||
*
|
||||
* A calendar provider keeps everything it fetches, beyond the reach of any
|
||||
* revoke. So this seeds every kind of day-log content the record can hold —
|
||||
* a note, custom symptom labels, a basal temperature, an ovulation test, the
|
||||
* mucus and cervix signs, intercourse, contraception, a pregnancy test — as
|
||||
* recognisable sentinels on the very rows the feed reads, as if a select had
|
||||
* been widened by mistake, and asserts that no rendered body contains any of
|
||||
* them, in either title mode, with history and the fertile window on.
|
||||
*
|
||||
* Written, then broken once on purpose (a description that interpolated the
|
||||
* day's note) to prove it fails.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { buildCycleFeed } from "@/lib/cycle/feed/build";
|
||||
import { renderIcs } from "@/lib/cycle/ics";
|
||||
|
||||
import {
|
||||
FEED,
|
||||
SUBJECT,
|
||||
profileRow,
|
||||
regularCycles,
|
||||
} from "@/lib/cycle/feed/__tests__/fixtures";
|
||||
|
||||
const db = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const SENTINELS = {
|
||||
note: "SENTINEL-NOTE-zebra",
|
||||
noteEncrypted: "SENTINEL-CIPHER-okapi",
|
||||
symptom: "SENTINEL-SYMPTOM-quokka",
|
||||
symptomKey: "sentinel_symptom_key",
|
||||
bbt: "36.987",
|
||||
wrist: "35.4321",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
mucus: "EGG_WHITE",
|
||||
cervix: "HIGH",
|
||||
sex: "SENTINEL-SEX-tapir",
|
||||
contraceptive: "SENTINEL-CONTRACEPTIVE-ibis",
|
||||
pregnancyTest: "SENTINEL-PREGNANCY-lynx",
|
||||
userId: "subject",
|
||||
};
|
||||
|
||||
const FIRST = "2025-11-18";
|
||||
const NOW = new Date("2026-11-06T12:00:00.000Z");
|
||||
|
||||
function stuffedDayLogs() {
|
||||
const cycles = regularCycles(FIRST);
|
||||
const out = [];
|
||||
for (const c of cycles) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
out.push({
|
||||
id: `log-${c.startDate}-${i}`,
|
||||
userId: SENTINELS.userId,
|
||||
date: addDays(c.startDate, i),
|
||||
flow: i < 4 ? "MEDIUM" : "NONE",
|
||||
basalBodyTempC: Number(SENTINELS.bbt),
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: SENTINELS.opk,
|
||||
cervicalMucus: SENTINELS.mucus,
|
||||
cervixPosition: SENTINELS.cervix,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: true,
|
||||
sexualActivity: SENTINELS.sex,
|
||||
protectedSex: false,
|
||||
pregnancyTest: SENTINELS.pregnancyTest,
|
||||
progesteroneTest: SENTINELS.pregnancyTest,
|
||||
contraceptive: SENTINELS.contraceptive,
|
||||
notes: SENTINELS.note,
|
||||
notesEncrypted: SENTINELS.noteEncrypted,
|
||||
sensitiveEncrypted: SENTINELS.noteEncrypted,
|
||||
symptomLinks: [
|
||||
{
|
||||
symptom: {
|
||||
key: SENTINELS.symptomKey,
|
||||
label: SENTINELS.symptom,
|
||||
},
|
||||
},
|
||||
],
|
||||
_count: { symptomLinks: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
|
||||
db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs());
|
||||
db.measurement.findMany.mockResolvedValue([
|
||||
{
|
||||
measuredAt: new Date("2026-11-01T03:00:00Z"),
|
||||
value: Number(SENTINELS.wrist),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("the calendar feed body", () => {
|
||||
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
|
||||
"carries no day-log content in %s mode",
|
||||
async (titleMode) => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode,
|
||||
historyCycles: 12,
|
||||
horizonCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
// Non-empty, or the assertion below would hold for nothing.
|
||||
expect(built.calendar.events.length).toBeGreaterThan(10);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
for (const [name, value] of Object.entries(SENTINELS)) {
|
||||
expect(body, `${name} leaked into the feed`).not.toContain(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("carries only the properties an all-day date event needs", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode: "DESCRIPTIVE",
|
||||
historyCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
const properties = new Set(
|
||||
body
|
||||
.split("\r\n")
|
||||
.filter((line) => line && !line.startsWith(" "))
|
||||
.map((line) => line.split(/[:;]/)[0]),
|
||||
);
|
||||
expect([...properties].sort()).toEqual(
|
||||
[
|
||||
"BEGIN",
|
||||
"CALSCALE",
|
||||
"CLASS",
|
||||
"DESCRIPTION",
|
||||
"DTEND",
|
||||
"DTSTAMP",
|
||||
"DTSTART",
|
||||
"END",
|
||||
"LAST-MODIFIED",
|
||||
"METHOD",
|
||||
"PRODID",
|
||||
"REFRESH-INTERVAL",
|
||||
"SUMMARY",
|
||||
"TRANSP",
|
||||
"UID",
|
||||
"VERSION",
|
||||
"X-PUBLISHED-TTL",
|
||||
"X-WR-CALNAME",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,7 @@ const NO_REFETCH_ALLOWLIST = [
|
||||
// Background integration-sync completion on the Settings surface — the user
|
||||
// is configuring an integration, not acting on the hero; convergence via the
|
||||
// daily reads' own poll/focus refetch is the intended behaviour.
|
||||
"components/settings/integrations/garmin-card.tsx",
|
||||
"components/settings/integrations/google-health-card.tsx",
|
||||
"components/settings/integrations/integration-actions.tsx",
|
||||
"components/settings/integrations/nightscout-card.tsx",
|
||||
|
||||
@@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/cycle/use-cycle-feed.ts",
|
||||
destroys:
|
||||
"the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link",
|
||||
triggers: ["components/cycle/cycle-calendar-feed-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── Medications ─────────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -719,6 +727,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/garmin-card.tsx",
|
||||
destroys:
|
||||
"the stored Garmin Personal API key — reconnecting means pasting a key again, and a key Garmin showed only once may mean creating a new one",
|
||||
triggers: ["components/settings/integrations/integration-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/dawarich-card.tsx",
|
||||
destroys:
|
||||
|
||||
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(106);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
|
||||
expect(ALL_LEAF_IDS.length).toBe(111);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
||||
});
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("discovers all nine shipped locales", () => {
|
||||
it("discovers all ten shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
@@ -177,6 +177,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -79,10 +79,22 @@ function imageCheck(): Check {
|
||||
};
|
||||
}
|
||||
|
||||
function wgetTimeout(command: string): number {
|
||||
const m = /wget[^|]*--timeout=(\d+)/.exec(command);
|
||||
if (!m) throw new Error(`no wget timeout in: ${command}`);
|
||||
return Number(m[1]);
|
||||
/**
|
||||
* The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The image
|
||||
* carries no curl, and its `wget` is a shim kept for health checks written
|
||||
* outside the repository (`wget-shim.test.ts`); the image's own check and the
|
||||
* bundled compose file do not lean on it, so neither names one.
|
||||
*/
|
||||
function probeTimeout(command: string): number {
|
||||
expect(command, "the image's own probe is the Node fetch").not.toMatch(
|
||||
/\b(wget|curl)\b/,
|
||||
);
|
||||
const m =
|
||||
/fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health',\{signal:AbortSignal\.timeout\((\d+)\)\}\)/.exec(
|
||||
command,
|
||||
);
|
||||
if (!m) throw new Error(`no aborting /api/health fetch in: ${command}`);
|
||||
return Number(m[1]) / 1000;
|
||||
}
|
||||
|
||||
describe.each([
|
||||
@@ -96,9 +108,9 @@ describe.each([
|
||||
|
||||
it("ends the request inside Docker's timeout, after the database probe's", () => {
|
||||
const check = read();
|
||||
const wget = wgetTimeout(check.command);
|
||||
expect(wget).toBeGreaterThan(probeTimeoutSeconds);
|
||||
expect(wget).toBeLessThan(check.timeout);
|
||||
const abort = probeTimeout(check.command);
|
||||
expect(abort).toBeGreaterThan(probeTimeoutSeconds);
|
||||
expect(abort).toBeLessThan(check.timeout);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean {
|
||||
* reason. Prune an entry only when the value genuinely diverges in a locale.
|
||||
*/
|
||||
const LEGIT_IDENTICAL = new Set<string>([
|
||||
// #1350 — "Google Calendar" is the product's own name in the Spanish and
|
||||
// Italian UI, and "Cycle (HealthLog)" is the French word for the same thing.
|
||||
"cycle.feed.fetcher.GOOGLE",
|
||||
"cycle.feed.ics.calendarName",
|
||||
// Vaccine catalogue: clinical proper nouns whose accepted written form is the
|
||||
// same across these locales. "Hib" carries its Latin binomial verbatim, and
|
||||
// "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue
|
||||
@@ -110,6 +114,14 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"settings.integrations.oura.scope.items.vo2max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
// Garmin's own names: the Body Battery score and the Personal API product,
|
||||
// which Garmin Connect shows under the same English name in every locale.
|
||||
"measurements.typeBodyBattery",
|
||||
"settings.garminTag",
|
||||
"settings.sections.sources.metrics.bodyBattery",
|
||||
// Garmin's own service names.
|
||||
"settings.garminRegionGlobal",
|
||||
"settings.garminRegionChina",
|
||||
"admin.glitchtipDsn",
|
||||
"admin.integrationWebPush",
|
||||
"admin.offlineGeoEnabled",
|
||||
|
||||
@@ -30,6 +30,14 @@ const hasSeparator = (value: string) =>
|
||||
/** Key path -> why the separator is right there. Empty by intent. */
|
||||
const ALLOWED: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Bundles where a spaced dash is punctuation the language requires rather than
|
||||
* a fragment join. Russian uses the dash where English puts a comma or a full
|
||||
* stop ("Сводка за неделю — всё спокойно"), so the ru bundle is exempt from
|
||||
* the dash rule; the middle dot is still flagged there.
|
||||
*/
|
||||
const DASH_EXEMPT_BUNDLES = new Set(["ru.json"]);
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
@@ -66,7 +74,13 @@ describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () =>
|
||||
|
||||
it(`${file} has no dot or dash separator outside the allowlist`, () => {
|
||||
const offenders = [...values]
|
||||
.filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED))
|
||||
.filter(([key, value]) => {
|
||||
if (!hasSeparator(value) || key in ALLOWED) return false;
|
||||
if (DASH_EXEMPT_BUNDLES.has(file) && !value.includes(" · ")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Per-envelope response/consumer guard for the labs categories.
|
||||
*
|
||||
* The registered Zod schemas are the producer: `LabOverviewResponse`
|
||||
* (`GET /api/labs/overview`), `ListBiomarkersResponse` (`GET /api/biomarkers`)
|
||||
* and `BiomarkerMergeResult` (`POST /api/biomarkers/{id}/merge`). Every leaf
|
||||
* of the parts this guard owns is walked out of the schema itself and must be
|
||||
* tied to the web reader that renders it, or to a named exemption with its
|
||||
* reason. A leaf added to a schema without either fails, so a field cannot
|
||||
* ship write-only. Matchers are qualified (`category.status`, not `status`),
|
||||
* so another response's field of the same name cannot satisfy them.
|
||||
*
|
||||
* Scope: the category fields and their two companions. The overview's
|
||||
* `markers` subtree predates this guard and is read through `LabResultDto`;
|
||||
* it is exempt as a whole, by name.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
biomarkerListResponse,
|
||||
biomarkerMergeResult,
|
||||
} from "@/lib/openapi/routes/biomarkers";
|
||||
import { labOverviewResponse } from "@/lib/openapi/routes/labs";
|
||||
|
||||
import { readSource } from "./helpers/source-files";
|
||||
|
||||
interface Def {
|
||||
type: string;
|
||||
shape?: Record<string, z.ZodType>;
|
||||
element?: z.ZodType;
|
||||
innerType?: z.ZodType;
|
||||
in?: z.ZodType;
|
||||
}
|
||||
|
||||
function defOf(schema: z.ZodType): Def {
|
||||
return (schema as unknown as { _zod: { def: Def } })._zod.def;
|
||||
}
|
||||
|
||||
/** Leaf paths of a schema: `a.b`, arrays as `a[]`. */
|
||||
function leaves(schema: z.ZodType, prefix: string): string[] {
|
||||
const def = defOf(schema);
|
||||
if (def.type === "object" && def.shape) {
|
||||
return Object.entries(def.shape).flatMap(([key, child]) =>
|
||||
leaves(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
if (def.type === "array" && def.element) {
|
||||
return leaves(def.element, `${prefix}[]`);
|
||||
}
|
||||
const inner = def.innerType ?? def.in;
|
||||
if (inner) return leaves(inner, prefix);
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
interface Reader {
|
||||
file: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
const LIST = "src/components/labs/lab-list.tsx";
|
||||
const GROUPS = "src/components/labs/lab-list-groups.tsx";
|
||||
const FORM_PANEL = "src/components/labs/biomarker-panel-field.tsx";
|
||||
const MERGE_SHEET = "src/components/labs/biomarker-merge-sheet.tsx";
|
||||
|
||||
const COUNTS_DESTRUCTURE = (name: string) =>
|
||||
new RegExp(`\\{[^}]*\\b${name}\\b[^}]*\\}\\s*=\\s*category\\.counts\\b`);
|
||||
|
||||
const READERS: Record<string, Reader> = {
|
||||
"overview:grouped": { file: LIST, pattern: /\bdata\?\.grouped\b/ },
|
||||
"overview:categories[].key": { file: GROUPS, pattern: /\bcategory\.key\b/ },
|
||||
"overview:categories[].label": {
|
||||
file: GROUPS,
|
||||
pattern: /\{category\.label\}/,
|
||||
},
|
||||
"overview:categories[].markerKeys[]": {
|
||||
file: GROUPS,
|
||||
pattern: /\bof\s+category\.markerKeys\b/,
|
||||
},
|
||||
"overview:categories[].counts.inRange": {
|
||||
file: GROUPS,
|
||||
pattern: COUNTS_DESTRUCTURE("inRange"),
|
||||
},
|
||||
"overview:categories[].counts.below": {
|
||||
file: GROUPS,
|
||||
pattern: COUNTS_DESTRUCTURE("below"),
|
||||
},
|
||||
"overview:categories[].counts.above": {
|
||||
file: GROUPS,
|
||||
pattern: COUNTS_DESTRUCTURE("above"),
|
||||
},
|
||||
"overview:categories[].counts.unknown": {
|
||||
file: GROUPS,
|
||||
pattern: COUNTS_DESTRUCTURE("unknown"),
|
||||
},
|
||||
"overview:categories[].status": {
|
||||
file: GROUPS,
|
||||
pattern: /\bcategory\.status\s*===\s*"above"/,
|
||||
},
|
||||
"biomarkers:customPanels[]": {
|
||||
file: FORM_PANEL,
|
||||
pattern: /\bdata\?\.customPanels\b/,
|
||||
},
|
||||
"merge:adoptedPanel": {
|
||||
file: MERGE_SHEET,
|
||||
pattern: /\bpreview\.adoptedPanel\b/,
|
||||
},
|
||||
};
|
||||
|
||||
/** Leaves this guard does not own, with the reason. Prefix match on `[]`. */
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"overview:markers[]":
|
||||
"the per-marker rows predate the categories and are rendered through LabResultDto in lab-list-rows.tsx",
|
||||
};
|
||||
|
||||
function ownedLeaves(): string[] {
|
||||
const overview = leaves(labOverviewResponse, "").map((l) => `overview:${l}`);
|
||||
const list = leaves(biomarkerListResponse, "")
|
||||
.filter((l) => l.startsWith("customPanels"))
|
||||
.map((l) => `biomarkers:${l}`);
|
||||
const merge = leaves(biomarkerMergeResult, "")
|
||||
.filter((l) => l === "adoptedPanel")
|
||||
.map((l) => `merge:${l}`);
|
||||
return [...overview, ...list, ...merge];
|
||||
}
|
||||
|
||||
function isExempt(leaf: string): boolean {
|
||||
return Object.keys(EXEMPT).some((prefix) => leaf.startsWith(prefix));
|
||||
}
|
||||
|
||||
describe("labs categories response/consumer guard", () => {
|
||||
const owned = ownedLeaves();
|
||||
|
||||
it("walks the schemas it guards", () => {
|
||||
// Nine category leaves, `grouped`, the panels and the adopted panel, plus
|
||||
// the exempt marker rows; far fewer means the walk broke.
|
||||
expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe(
|
||||
Object.keys(READERS).length,
|
||||
);
|
||||
expect(owned.filter(isExempt).length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("ties every leaf to a reader or a named exemption", () => {
|
||||
const orphans = owned.filter((leaf) => !isExempt(leaf) && !READERS[leaf]);
|
||||
expect(orphans).toEqual([]);
|
||||
const stale = Object.keys(READERS).filter((leaf) => !owned.includes(leaf));
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
for (const [leaf, reader] of Object.entries(READERS)) {
|
||||
it(`${leaf} is read in ${reader.file}`, () => {
|
||||
// `readSource` throws when the anchor is gone from the file.
|
||||
const source = readSource(reader.file, reader.pattern);
|
||||
expect(reader.pattern.test(source)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it("cannot pass on a reader that reads nothing", () => {
|
||||
expect(
|
||||
COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"),
|
||||
).toBe(false);
|
||||
expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Every analyte the catalog can recognise by name has a catalog category.
|
||||
*
|
||||
* The labs list groups a marker without a panel by the analyte its name
|
||||
* resolves to (`catalogKeyForName`), and that analyte's panel comes from its
|
||||
* seed or, for an analyte with no seed, from `ALIAS_PANEL`. An alias key added
|
||||
* to `ANALYTE_ALIASES` or `LAB_NAME_ALIASES` without an `ALIAS_PANEL` entry
|
||||
* would put every marker of that analyte under "Other" without a word. This
|
||||
* holds the lists together.
|
||||
*
|
||||
* The check is a function of the alias table, so the guard proves it can
|
||||
* fail: the same check over the table minus one entry must name that entry.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ALIAS_PANEL,
|
||||
ANALYTE_ALIASES,
|
||||
BIOMARKER_CATALOG,
|
||||
BIOMARKER_PANELS,
|
||||
LAB_NAME_ALIASES,
|
||||
type BiomarkerPanelKey,
|
||||
} from "@/lib/labs/biomarker-catalog";
|
||||
|
||||
function uncategorised(
|
||||
aliasPanel: Readonly<Record<string, BiomarkerPanelKey>>,
|
||||
): { checked: number; missing: string[] } {
|
||||
const panels = new Set<string>(BIOMARKER_PANELS);
|
||||
const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel]));
|
||||
const keys = new Set<string>([
|
||||
...BIOMARKER_CATALOG.map((s) => s.slug),
|
||||
...ANALYTE_ALIASES.map((a) => a.key),
|
||||
...LAB_NAME_ALIASES.map((a) => a.key),
|
||||
]);
|
||||
const missing: string[] = [];
|
||||
for (const key of keys) {
|
||||
const panel = seedPanel.get(key) ?? aliasPanel[key];
|
||||
if (panel === undefined || !panels.has(panel)) missing.push(key);
|
||||
}
|
||||
return { checked: keys.size, missing };
|
||||
}
|
||||
|
||||
describe("marker category coverage", () => {
|
||||
it("gives every seed and alias key a catalog panel", () => {
|
||||
const { checked, missing } = uncategorised(ALIAS_PANEL);
|
||||
expect(checked).toBeGreaterThan(BIOMARKER_CATALOG.length);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no alias panel for a key the catalog does not know", () => {
|
||||
const known = new Set<string>([
|
||||
...ANALYTE_ALIASES.map((a) => a.key),
|
||||
...LAB_NAME_ALIASES.map((a) => a.key),
|
||||
]);
|
||||
const seeds = new Set(BIOMARKER_CATALOG.map((s) => s.slug));
|
||||
const stale = Object.keys(ALIAS_PANEL).filter(
|
||||
(key) => !known.has(key) || seeds.has(key),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when an alias panel entry is removed", () => {
|
||||
const [dropped] = Object.keys(ALIAS_PANEL);
|
||||
const reduced = Object.fromEntries(
|
||||
Object.entries(ALIAS_PANEL).filter(([key]) => key !== dropped),
|
||||
);
|
||||
expect(uncategorised(reduced).missing).toEqual([dropped]);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,8 @@ const WRITE_SITES: Record<string, string> = {
|
||||
"lib/whoop/webhook-handler.ts": TAIL,
|
||||
// Retiring the rows of Oura sleep periods the person rejected or deleted.
|
||||
"lib/oura/sync.ts": TAIL,
|
||||
// Retiring the rows of nights Garmin no longer reports.
|
||||
"lib/garmin/sync.ts": TAIL,
|
||||
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
|
||||
"lib/oura/retire-distance.ts": TAIL,
|
||||
// Integration syncs: each collects the (type, day) pairs it touched and
|
||||
|
||||
@@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Starts the identity-provider handoff, for the browser and for the native client.",
|
||||
"/api/auth/oidc/status":
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/cycle/feed/{token}":
|
||||
"A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
"/api/auth/claim/preview":
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NextRequest } from "next/server";
|
||||
* - `frame-src` exists and admits precisely 'self' and blob: — nothing else.
|
||||
* - `img-src` admits blob: alongside 'self' and data:.
|
||||
* - The boundaries that make blob: safe stay untouched: `object-src 'none'`,
|
||||
* `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src.
|
||||
* `frame-ancestors 'self'`, `default-src 'self'`, nonce-bound script-src.
|
||||
* - The vault SERVE route keeps its own stricter document CSP unchanged.
|
||||
*
|
||||
* Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only
|
||||
@@ -73,7 +73,10 @@ describe("proxy.ts blob preview CSP (#787)", () => {
|
||||
const res = proxy(makeRequest("/documents"));
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(directive(csp, "object-src")).toEqual(["'none'"]);
|
||||
expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
|
||||
// 'self', never 'none': the blob: preview inherits this policy, and
|
||||
// WebKit applies the inherited frame-ancestors to the preview's own
|
||||
// parent, so 'none' blanks every PDF preview in Safari.
|
||||
expect(directive(csp, "frame-ancestors")).toEqual(["'self'"]);
|
||||
expect(directive(csp, "default-src")).toEqual(["'self'"]);
|
||||
expect(csp).toMatch(/script-src 'self' 'nonce-/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* #1350 — the cycle calendar feed at `/api/cycle/feed/<hlf_token>.ics`.
|
||||
*
|
||||
* A calendar provider polls it from its own servers with no cookie, so the
|
||||
* proxy must pass the request through untouched, keep it out of indexes and
|
||||
* referrers, and leave its caching to the route (`private, no-cache` plus an
|
||||
* ETag) rather than stamping `no-store` over the validator path. The
|
||||
* management routes beside it stay cookie-only and are not on the list.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { PUBLIC_PATHS, proxy } from "../proxy";
|
||||
|
||||
// Built at run time so no token-shaped literal sits in the source.
|
||||
const TOKEN = `hlf_${"7d".repeat(24)}`;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("proxy on /api/cycle/feed/<token>", () => {
|
||||
it("lists the feed prefix, trailing slash included, and not the management namespace", () => {
|
||||
expect(PUBLIC_PATHS).toContain("/api/cycle/feed/");
|
||||
expect(
|
||||
PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a cookie-less GET through to the route", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, {
|
||||
headers: { "user-agent": "Google-Calendar-Importer" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("x-middleware-next")).toBe("1");
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the token out of indexes and referrers", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("leaves the route's Cache-Control alone, so the ETag path survives", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a demo-mode mint at the edge", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest("http://localhost/api/cycle/feed-subscription", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user