mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge release/v1.47.0
This commit is contained in:
913 files changed
+37535
-5717
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"
|
||||
|
||||
|
||||
|
||||
+16
-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,11 +714,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
|
||||
|
||||
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
|
||||
# The bundled renderer needs a recent CPU: AVX2 on x86-64, and ARMv8.2 features
|
||||
# (LSE atomics, RCpc) on arm64. On older CPUs (Celeron/Atom-class NAS chips, the
|
||||
# Raspberry Pi 4) 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 PDF processing if the CPU truly lacks them)
|
||||
# NATIVE_CANVAS="off"
|
||||
|
||||
@@ -64,6 +64,15 @@ 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 `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
|
||||
|
||||
@@ -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
|
||||
@@ -162,3 +162,6 @@ jobs:
|
||||
format: table
|
||||
severity: "CRITICAL"
|
||||
exit-code: "1"
|
||||
# Same rule as the blocking gate in security.yml: a finding with a
|
||||
# published fix fails, one without is in the SARIF report above.
|
||||
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
|
||||
|
||||
+126
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
+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=^1.19.15' && \
|
||||
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.9 && \
|
||||
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 "$@"
|
||||
+1
-1
@@ -50,7 +50,7 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
<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
|
||||
|
||||
+1512
-196
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.
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Geplant",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Quelle",
|
||||
"intakeHistoryInjectionSite": "Injektionsstelle: {site}",
|
||||
"intakeHistoryStatusTaken": "Eingenommen",
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "Punkte",
|
||||
"unitMinutes": "Min.",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "Belastung"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Schlafqualität",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Externes Gerät",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Uhr",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryInjectionSite": "Injection site: {site}",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "load"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Sleep quality",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "External device",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,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 +4764,8 @@
|
||||
"unitScore": "puntos",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ppm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Calidad del sueño",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Reloj",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Programmée",
|
||||
"intakeHistoryColStatus": "Statut",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryInjectionSite": "Site d'injection : {site}",
|
||||
"intakeHistoryStatusTaken": "Prise",
|
||||
"intakeHistoryStatusSkipped": "Ignorée",
|
||||
"intakeHistorySortByTaken": "Trier par heure de prise",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "points",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "charge"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualité du sommeil",
|
||||
@@ -4780,7 +4787,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."
|
||||
},
|
||||
@@ -4971,6 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Appareil externe",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Montre",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Programmata",
|
||||
"intakeHistoryColStatus": "Stato",
|
||||
"intakeHistoryColSource": "Sorgente",
|
||||
"intakeHistoryInjectionSite": "Sito di iniezione: {site}",
|
||||
"intakeHistoryStatusTaken": "Assunta",
|
||||
"intakeHistoryStatusSkipped": "Saltata",
|
||||
"intakeHistorySortByTaken": "Ordina per ora di assunzione",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "punti",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carico"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualità del sonno",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo esterno",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Orologio",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "예정",
|
||||
"intakeHistoryColStatus": "상태",
|
||||
"intakeHistoryColSource": "출처",
|
||||
"intakeHistoryInjectionSite": "주사 부위: {site}",
|
||||
"intakeHistoryStatusTaken": "복용함",
|
||||
"intakeHistoryStatusSkipped": "건너뜀",
|
||||
"intakeHistorySortByTaken": "복용 시각순 정렬",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "점",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "부하"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "수면 품질",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,11 @@
|
||||
"recovery": "회복",
|
||||
"stress": "스트레스",
|
||||
"sleepDebt": "수면 부채",
|
||||
"hrvRmssd": "심박변이도 (RMSSD)"
|
||||
"hrvRmssd": "심박변이도 (RMSSD)",
|
||||
"bodyBattery": "바디 배터리",
|
||||
"stressLevel": "스트레스 수준",
|
||||
"trainingReadiness": "트레이닝 준비도",
|
||||
"acuteTrainingLoad": "급성 훈련 부하"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5671,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "외부 기기",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "워치",
|
||||
@@ -7465,11 +7515,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 +7714,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": "관리자",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"fileTooLarge": "파일이 너무 커요 (최대 12MB).",
|
||||
"fileType": "JPEG, PNG, WebP 또는 PDF를 올려 주세요.",
|
||||
"extractFailed": "결과지를 읽지 못했어요. 더 선명한 사진으로 시도해 주세요.",
|
||||
"pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.",
|
||||
"pdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 검사지는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있어요. 대신 사진을 올려 주세요.",
|
||||
"refTextLabel": "적힌 그대로의 범위",
|
||||
"refTextPlaceholder": "예: 3.5 - 5.0",
|
||||
"aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다.",
|
||||
@@ -11960,7 +12056,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는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
|
||||
}
|
||||
@@ -12043,7 +12139,7 @@
|
||||
"errorRateLimitedWaitFew": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
|
||||
"errorRateLimitedWaitOther": "시간당 AI 읽기 한도에 도달했어요. {count}분쯤 뒤에 다시 시도해 주세요.",
|
||||
"errorBudget": "오늘 쓸 수 있는 AI 예산을 다 썼어요.",
|
||||
"errorPdfNeedsVision": "PDF를 읽으려면 Claude 비전 제공자가 필요해요.",
|
||||
"errorPdfRendererUnavailable": "이 서버의 프로세서로는 PDF 페이지를 렌더링할 수 없어요. 스캔한 PDF는 PDF 파일을 직접 받는 AI 제공자만 읽을 수 있고, 페이지를 사진으로 올려도 돼요.",
|
||||
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Gepland",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Bron",
|
||||
"intakeHistoryInjectionSite": "Injectieplaats: {site}",
|
||||
"intakeHistoryStatusTaken": "Ingenomen",
|
||||
"intakeHistoryStatusSkipped": "Overgeslagen",
|
||||
"intakeHistorySortByTaken": "Sorteren op innametijd",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "belasting"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Slaapkwaliteit",
|
||||
@@ -4777,7 +4784,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Extern apparaat",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Horloge",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,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 +4764,8 @@
|
||||
"unitScore": "pkt",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ud./min",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "obciążenie"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Jakość snu",
|
||||
@@ -4780,7 +4787,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 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,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",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+110
-14
@@ -969,7 +969,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 +1900,7 @@
|
||||
"intakeHistoryColScheduled": "Programada",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Fonte",
|
||||
"intakeHistoryInjectionSite": "Local da injeção: {site}",
|
||||
"intakeHistoryStatusTaken": "Tomada",
|
||||
"intakeHistoryStatusSkipped": "Pulada",
|
||||
"intakeHistorySortByTaken": "Ordenar por horário da tomada",
|
||||
@@ -4758,7 +4764,8 @@
|
||||
"unitScore": "pontuação",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualidade do sono",
|
||||
@@ -4780,7 +4787,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."
|
||||
},
|
||||
@@ -4971,6 +4982,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": {
|
||||
@@ -5653,7 +5698,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 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Relógio",
|
||||
@@ -7465,11 +7515,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 +7714,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",
|
||||
@@ -10393,7 +10489,7 @@
|
||||
"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.",
|
||||
@@ -11960,7 +12056,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."
|
||||
}
|
||||
@@ -12043,7 +12139,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.",
|
||||
|
||||
+14201
File diff suppressed because it is too large.
Load diff
+4
-6
@@ -327,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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.46.6",
|
||||
"version": "1.47.0",
|
||||
"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",
|
||||
|
||||
@@ -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$;
|
||||
@@ -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
|
||||
@@ -1783,6 +1800,13 @@ 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).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -2018,6 +2042,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")
|
||||
}
|
||||
|
||||
@@ -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.6";
|
||||
/* @sw-version-fallback */ "v1.47.0";
|
||||
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/*`
|
||||
|
||||
@@ -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(
|
||||
|
||||
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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94292,
|
||||
"total": 94309,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -22,14 +22,14 @@
|
||||
"components/timeline/timeline-geometry.ts": 1288,
|
||||
"lib/ai/anthropic-client.ts": 804,
|
||||
"lib/ai/coach/about-me.ts": 1012,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1079,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1091,
|
||||
"lib/ai/coach/persistence.ts": 1218,
|
||||
"lib/ai/coach/results/metric-table-tool.ts": 1102,
|
||||
"lib/ai/coach/snapshot.ts": 1346,
|
||||
"lib/ai/coach/stream-events.ts": 817,
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/codex-client.ts": 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,7 +39,7 @@
|
||||
"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,
|
||||
@@ -62,7 +62,7 @@
|
||||
"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,
|
||||
@@ -70,7 +70,7 @@
|
||||
"lib/jobs/reaction-line.ts": 803,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1397,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 345,
|
||||
"total": 111918,
|
||||
"total": 111894,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -21,7 +21,7 @@
|
||||
"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,
|
||||
@@ -156,7 +156,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,
|
||||
@@ -293,7 +293,7 @@
|
||||
"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,
|
||||
@@ -335,7 +335,7 @@
|
||||
"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/personal-records/pr-direction.ts :: getPRDirection": 208,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1378,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,
|
||||
@@ -1959,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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -719,6 +719,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(110);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,6 +110,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([]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,7 @@ describe("record settings contract", () => {
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
garmin: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
@@ -189,6 +190,7 @@ describe("record settings contract", () => {
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
garmin: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The runtime image is pinned by image digest, carries no package manager,
|
||||
* and its PostgreSQL client moves with the bundled database.
|
||||
*
|
||||
* Node comes from the official node:22-trixie-slim image and pg_dump /
|
||||
* pg_restore from the official PostgreSQL image, both by digest. Without
|
||||
* the digest a rebuild of the same commit could ship another Node or
|
||||
* another pg_dump than the one the release was checked with. Dependabot's
|
||||
* `docker` ecosystem proposes the next digest or minor; the image scan
|
||||
* names a fix the pinned base has not absorbed. This file holds the pins'
|
||||
* shape, the runner's lack of npm, and the client's agreement with the
|
||||
* bundled database.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
||||
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
|
||||
|
||||
const DIGEST = /@sha256:[0-9a-f]{64}$/;
|
||||
|
||||
function froms(): { image: string; stage: string }[] {
|
||||
return [...DOCKERFILE.matchAll(/^FROM (\S+) AS (\S+)$/gm)].map((m) => ({
|
||||
image: m[1]!,
|
||||
stage: m[2]!,
|
||||
}));
|
||||
}
|
||||
|
||||
function stage(name: string): string {
|
||||
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
|
||||
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
|
||||
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
|
||||
return next < 0
|
||||
? DOCKERFILE.slice(start)
|
||||
: DOCKERFILE.slice(start, start + 1 + next);
|
||||
}
|
||||
|
||||
/** The runner's instructions, comments dropped. */
|
||||
function runnerInstructions(): string {
|
||||
return stage("runner")
|
||||
.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("#"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("runtime package pins", () => {
|
||||
it("pins every stage's base image by digest", () => {
|
||||
const all = froms();
|
||||
// Every FROM names a stage, so none escapes the list above.
|
||||
expect(DOCKERFILE.match(/^FROM /gm)).toHaveLength(all.length);
|
||||
for (const { image, stage: name } of all) {
|
||||
expect(image, `${name} pins its base by digest`).toMatch(DIGEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs on the builder's own official Node 22 image", () => {
|
||||
const byStage = new Map(froms().map((f) => [f.stage, f.image]));
|
||||
const runner = byStage.get("runner");
|
||||
expect(runner).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/);
|
||||
// Same image as the stage the native packages were installed in.
|
||||
expect(runner).toBe(byStage.get("builder"));
|
||||
});
|
||||
|
||||
it("removes npm, npx, corepack and yarn from the runner and runs none of them", () => {
|
||||
const runner = runnerInstructions();
|
||||
for (const path of [
|
||||
"/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",
|
||||
]) {
|
||||
expect(runner).toContain(path);
|
||||
}
|
||||
expect(runner).toContain(
|
||||
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
|
||||
);
|
||||
// Nothing in the runner installs a package manager or runs one.
|
||||
expect(runner).not.toMatch(/\b(npm|npx|corepack|yarn|pnpm) [a-z]/);
|
||||
expect(runner).not.toMatch(/apt-get install[^;&]*\b(npm|nodejs)\b/);
|
||||
expect(runner).not.toMatch(/\bapk\b/);
|
||||
});
|
||||
|
||||
it("takes the PostgreSQL client from the bundled database's minor", () => {
|
||||
const pg = froms().find((f) => f.stage === "pgclient");
|
||||
expect(pg, "the Dockerfile has a pgclient stage").toBeDefined();
|
||||
const client = /^postgres:(18\.\d+)-trixie@sha256:[0-9a-f]{64}$/.exec(
|
||||
pg!.image,
|
||||
);
|
||||
expect(client, `a pinned major 18 image, not ${pg!.image}`).not.toBeNull();
|
||||
// The `db` service's own image line, not the first postgres image in the
|
||||
// file: `db-upgrade` carries the previous major.
|
||||
const db =
|
||||
/^ {2}db:\n(?:(?! {2}\S).*\n)*? {4}image:\s*postgres:(\d+\.\d+)-alpine$/m.exec(
|
||||
COMPOSE,
|
||||
);
|
||||
expect(db, "the compose file pins the database").not.toBeNull();
|
||||
// One decision: a database minor bump moves the client with it.
|
||||
expect(client![1]).toBe(db![1]);
|
||||
expect(stage("runner")).toContain(
|
||||
"COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -908,6 +908,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.",
|
||||
},
|
||||
"app/api/measurements/blood-pressure/time-of-day/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's blood-pressure readings averaged by part of the day. The series read and the pulse lookup both bind the resolved user id, the hours are read in the record's own zone, and the route holds no write arm.",
|
||||
},
|
||||
"app/api/measurement-reminders/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
|
||||
@@ -2288,7 +2292,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* manage literal: it was refused under a switch while the medication's PUT,
|
||||
* which used to carry the end, admitted a manager. 272 -> 274.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 274;
|
||||
const FROZEN_ENTRY_COUNT = 275;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -47,10 +47,6 @@ const ALLOWED: Record<string, { hits: number; why: string }> = {
|
||||
hits: 1,
|
||||
why: "pg-boss tables are timestamptz",
|
||||
},
|
||||
"src/lib/db-compat.ts": {
|
||||
hits: 1,
|
||||
why: "DDL default on a timestamptz column",
|
||||
},
|
||||
};
|
||||
|
||||
const SQL_CLOCK =
|
||||
|
||||
@@ -288,7 +288,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
|
||||
"toast.success": 2,
|
||||
@@ -417,6 +417,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/integrations/nightscout-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
// The connect form's "connected" line, the Nightscout form's twin.
|
||||
"src/components/settings/integrations/garmin-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
// A paused connection resumed.
|
||||
"src/components/settings/integrations/oauth-integration-card.tsx": {
|
||||
"text-success": 1,
|
||||
|
||||
@@ -65,9 +65,6 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
vi.mock("@/lib/analytics/mood-series", () => ({
|
||||
buildMoodDailySeries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The image's `wget` answers the health checks written outside this
|
||||
* repository the way wget would.
|
||||
*
|
||||
* The runtime base carries no wget. A compose file copied from this
|
||||
* repository up to v1.46.6 checks health with
|
||||
* `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`,
|
||||
* and the TrueNAS catalog app renders `wget --quiet --spider <url>`; either
|
||||
* replaces the image's own HEALTHCHECK. Without a `wget` such a container is
|
||||
* unhealthy forever and a proxy that routes on health drops its traffic. So
|
||||
* the image installs `scripts/wget-shim.mjs` as `/usr/local/bin/wget`, and
|
||||
* this file runs that script as the health check would: exit 0 on a healthy
|
||||
* answer, 1 on a 503, a refused connection or a timeout, and no file
|
||||
* written anywhere.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SHIM = join(ROOT, "scripts", "wget-shim.mjs");
|
||||
|
||||
let server: Server;
|
||||
let base = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
if (req.url === "/ok") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end('{"status":"ok"}');
|
||||
} else if (req.url === "/down") {
|
||||
res.writeHead(503, { "content-type": "application/json" });
|
||||
res.end('{"status":"degraded"}');
|
||||
} else if (req.url === "/moved") {
|
||||
res.writeHead(302, { location: "http://192.0.2.1/elsewhere" });
|
||||
res.end();
|
||||
} else if (req.url === "/slow") {
|
||||
// Never answers; the shim's timeout has to end the request.
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function wget(
|
||||
args: string[],
|
||||
cwd = ROOT,
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
ms: number;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const child = spawn(process.execPath, [SHIM, ...args], { cwd });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
||||
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
||||
child.on("close", (code) =>
|
||||
resolve({ code, stdout, stderr, ms: Date.now() - started }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** A port nothing listens on: bind one, note it, close it again. */
|
||||
async function closedPort(): Promise<number> {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
// The two command lines found in the wild, with the URL left to the case.
|
||||
const LINES: Array<[string, (url: string) => string[]]> = [
|
||||
[
|
||||
"compose up to v1.46.6",
|
||||
(url) => ["--no-verbose", "--tries=1", "--timeout=4", "--spider", url],
|
||||
],
|
||||
[
|
||||
"compose without a timeout",
|
||||
(url) => ["--no-verbose", "--tries=1", "--spider", url],
|
||||
],
|
||||
["TrueNAS catalog app", (url) => ["--quiet", "--spider", url]],
|
||||
];
|
||||
|
||||
describe.each(LINES)("the %s health check", (_name, line) => {
|
||||
it("passes on a healthy server", async () => {
|
||||
expect((await wget(line(`${base}/ok`))).code).toBe(0);
|
||||
});
|
||||
|
||||
it("fails on a 503", async () => {
|
||||
expect((await wget(line(`${base}/down`))).code).toBe(1);
|
||||
});
|
||||
|
||||
it("fails on a refused connection", async () => {
|
||||
const port = await closedPort();
|
||||
expect((await wget(line(`http://127.0.0.1:${port}/api/health`))).code).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wget shim", () => {
|
||||
it("gives up at its timeout instead of hanging", async () => {
|
||||
const result = await wget(["-q", "-T", "1", "--spider", `${base}/slow`]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.ms).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it("counts a redirect as an answer and does not follow it", async () => {
|
||||
expect((await wget(["-q", "--spider", `${base}/moved`])).code).toBe(0);
|
||||
});
|
||||
|
||||
it("prints the body for -O - and stays quiet with -q", async () => {
|
||||
const result = await wget(["-qO-", `${base}/ok`]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe('{"status":"ok"}');
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("accepts -O /dev/null and -S", async () => {
|
||||
const result = await wget(["-S", "-O", "/dev/null", `${base}/ok`]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toMatch(/HTTP\/1\.1 200/);
|
||||
});
|
||||
|
||||
it("ignores flags it does not know", async () => {
|
||||
const result = await wget([
|
||||
"--no-check-certificate",
|
||||
"--header",
|
||||
"X-Probe: 1",
|
||||
"-c",
|
||||
"--spider",
|
||||
`${base}/ok`,
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it("is not a download tool: it refuses any other output file and writes none", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wget-shim-"));
|
||||
try {
|
||||
const target = join(dir, "health.json");
|
||||
const refused = await wget(["-O", target, `${base}/ok`], dir);
|
||||
expect(refused.code).toBe(2);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
// Without -O real wget saves `index.html`; the shim discards the body.
|
||||
const bare = await wget([`${base}/ok`], dir);
|
||||
expect(bare.code).toBe(0);
|
||||
expect(existsSync(join(dir, "ok"))).toBe(false);
|
||||
expect(existsSync(join(dir, "index.html"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a command line without a URL", async () => {
|
||||
expect((await wget(["--spider"])).code).toBe(2);
|
||||
});
|
||||
|
||||
it("takes a URL without a scheme, like busybox wget", async () => {
|
||||
const bare = base.replace(/^http:\/\//, "");
|
||||
expect((await wget(["-qO-", `${bare}/ok`])).code).toBe(0);
|
||||
expect((await wget(["-qO-", `${bare}/down`])).code).toBe(1);
|
||||
});
|
||||
|
||||
it("is what the image installs as wget", () => {
|
||||
const dockerfile = readSource("Dockerfile", "AS runner");
|
||||
const runner = dockerfile.slice(dockerfile.search(/^FROM .* AS runner$/m));
|
||||
expect(runner).toContain(
|
||||
"COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs",
|
||||
);
|
||||
expect(runner).toContain(
|
||||
`exec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\\n' > /usr/local/bin/wget`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
|
||||
writeFile: "lib/import/health-connect/import.ts",
|
||||
metadataKey: "healthConnectExerciseType",
|
||||
},
|
||||
{
|
||||
provider: "Garmin",
|
||||
mapper: "mapGarminSportType",
|
||||
mapperFile: "lib/garmin/sport-map.ts",
|
||||
captureFile: "lib/garmin/sync-workouts.ts",
|
||||
captureField: "a.activityType?.typeKey",
|
||||
writeFile: "lib/garmin/sync-workouts.ts",
|
||||
metadataKey: "garminActivity",
|
||||
},
|
||||
{
|
||||
provider: "Oura",
|
||||
mapper: "mapOuraSportType",
|
||||
|
||||
@@ -663,6 +663,13 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
|
||||
state: GOOGLE_HEALTH_STATE_EXCEPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "garmin",
|
||||
path: join(ROOT, "src/app/api/garmin/sync/route.ts"),
|
||||
responseClass: "resolved",
|
||||
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
|
||||
exceptions: {},
|
||||
},
|
||||
{
|
||||
id: "nightscout",
|
||||
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),
|
||||
|
||||
@@ -12,9 +12,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -12,9 +12,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -18,9 +18,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -60,6 +60,14 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
// Reset, not clear, before every case: cases replace the paused rows, the
|
||||
// settings row and the account groups, and a cleared mock would hand the
|
||||
// next case whatever the last one left there. A reset mock goes back to the
|
||||
// empty answer it was created with.
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
|
||||
const usageRows = vi.mocked(prisma.coachUsage.findMany);
|
||||
|
||||
@@ -101,7 +109,6 @@ function group(
|
||||
|
||||
describe("GET /api/admin/provider-health", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin1" },
|
||||
} as never);
|
||||
@@ -302,7 +309,6 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
const settings = vi.mocked(prisma.appSettings.findUnique);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin" },
|
||||
} as never);
|
||||
|
||||
@@ -15,9 +15,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -15,9 +15,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -75,10 +75,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/correlation-patterns", () => ({
|
||||
PATTERN_FAMILIES: { fixed: "FIXED" },
|
||||
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
|
||||
|
||||
@@ -58,10 +58,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "aa".repeat(32)),
|
||||
}));
|
||||
|
||||
@@ -61,10 +61,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
@@ -90,7 +86,6 @@ import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
|
||||
@@ -136,7 +131,6 @@ beforeEach(() => {
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(createSession).mockResolvedValue("session-id");
|
||||
});
|
||||
|
||||
@@ -52,10 +52,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
// Return a deterministic hex string that does NOT echo the raw
|
||||
// value — mirrors the real HMAC-SHA256 shape. The audit row's
|
||||
@@ -88,7 +84,6 @@ import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
|
||||
const TYPED_EMAIL = "leaked-identifier@example.com";
|
||||
|
||||
@@ -117,7 +112,6 @@ beforeEach(() => {
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
|
||||
|
||||
@@ -52,10 +52,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
refundRateLimit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -59,8 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
@@ -59,8 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
|
||||
@@ -14,10 +14,6 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
@@ -37,8 +36,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const parsed = await parseJsonBody(request, mfaWebauthnLoginOptionsSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
message: "Invalid request",
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
@@ -57,8 +56,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
|
||||
@@ -26,7 +26,6 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import { isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
@@ -74,8 +73,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// (3) Body — bounded, then the exact wire contract.
|
||||
const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -9,10 +9,6 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
|
||||
Loaded 100 of 913 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user