mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
2614 files changed
+360552
-69073
No files matched your search
+55
-6
@@ -202,7 +202,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
#
|
||||
# Set this to "1" ONLY when every public request to HealthLog terminates
|
||||
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
|
||||
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
|
||||
# origin, and the origin accepts connections from Cloudflare alone (a
|
||||
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
|
||||
# header is believed from every connection, before any other check.
|
||||
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
|
||||
# direct-to-Next.js exposure MUST leave this off.
|
||||
#
|
||||
# Security implications when the flag is mis-set:
|
||||
@@ -224,10 +227,19 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# Number of trusted reverse-proxy hops in front of the app. The client IP is
|
||||
# read that many entries from the right of the X-Forwarded-For chain. Set 0
|
||||
# for a directly-exposed host; 1 (the default when unset) behind a single
|
||||
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
|
||||
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
|
||||
# from a connection whose own address is not public (loopback, private, CGNAT,
|
||||
# unique-local, link-local, reserved or multicast; the full list is in
|
||||
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
|
||||
# refuse the headers, log one warning, and share one rate-limit bucket across
|
||||
# all anonymous callers.
|
||||
# TRUST_PROXY_HOPS="1"
|
||||
# The addresses the reverse proxy connects from (comma-separated CIDRs). Set,
|
||||
# only those connections may report the client's address; unset, any private
|
||||
# address may, which behind Docker Desktop, OrbStack or docker-proxy includes
|
||||
# every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker
|
||||
# network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md.
|
||||
# TRUSTED_PROXY_CIDRS=""
|
||||
# TRUST_CF_CONNECTING_IP=""
|
||||
|
||||
|
||||
@@ -277,6 +289,33 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
|
||||
# BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database dumps (optional, on by default)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The whole-database dump that rebuilds a lost server: nightly, and before
|
||||
# every update that brings migrations. Written to DB_BACKUP_DIR with a
|
||||
# manifest beside it (row counts, key fingerprints, versions); with the
|
||||
# off-host bucket above set, each nightly dump is also uploaded under
|
||||
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
|
||||
# encrypted: they sit beside the database volume, in the same trust zone.
|
||||
# Restore: `docker compose run --rm db-restore latest`. See
|
||||
# docs/ops/disaster-recovery.md.
|
||||
#
|
||||
# Set "false" to stop the nightly dump.
|
||||
# DB_BACKUP_ENABLED="true"
|
||||
# Where dumps are written. The bundled compose file mounts the `backups`
|
||||
# volume here; change it only together with that mount. In a container a
|
||||
# directory that is not on a mount of its own sits in the container's layer
|
||||
# and is lost at the next update, so no dump is kept there.
|
||||
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
|
||||
# Five-field cron, in Europe/Berlin time like every scheduled job.
|
||||
# DB_BACKUP_SCHEDULE="30 1 * * *"
|
||||
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
|
||||
# besides.
|
||||
# DB_BACKUP_KEEP="7"
|
||||
# Set "false" to start an update without the dump before its migrations.
|
||||
# DB_BACKUP_BEFORE_MIGRATE="true"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stored backups in the database (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -328,11 +367,21 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# Auto-deploy webhook (v1.4.15 — phase C2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy
|
||||
# attempt with success/failure. We verify the request via this shared secret
|
||||
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
|
||||
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
|
||||
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
|
||||
# attempt with success/failure. A request authenticates with this shared
|
||||
# secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what
|
||||
# Coolify's stock sender does, with no replay protection), or by signing:
|
||||
# `X-Deploy-Webhook-Signature: sha256=<hex>`, the HMAC-SHA256 under this
|
||||
# secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body,
|
||||
# the timestamp within 5 minutes. Generate with `openssl rand -hex 32`.
|
||||
# Configure the SAME value in Coolify's Notifications → Webhook
|
||||
# custom-headers panel. See docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_SECRET=""
|
||||
# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header
|
||||
# request; leave unset for Coolify's stock sender, which sends none.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
|
||||
# "true" accepts signed requests only (the setting that stops a replay);
|
||||
# leave unset for Coolify's stock sender.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+81
-14
@@ -295,11 +295,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. When unset the notification omits the link.
|
||||
# DEPLOY_LOGS_URL=""
|
||||
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
|
||||
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
|
||||
# call. Leave unset for Coolify's stock sender (it attaches no
|
||||
# timestamp); see docs/ops/deploy.md.
|
||||
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
|
||||
# request that authenticates with the secret header. Keeps a stale capture
|
||||
# out, not a fresh forgery by someone who saw the secret. Leave unset for
|
||||
# Coolify's stock sender, which sends no timestamp.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
# Optional replay protection: accept only signed webhook calls
|
||||
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
|
||||
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
|
||||
# refused. Leave unset for Coolify's stock sender, which can only send the
|
||||
# secret header; see docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -414,6 +420,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# key once the lifecycle rule has retired every object written under it.
|
||||
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database dumps (optional, on by default)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The whole-database dump that rebuilds a lost server: nightly, and before
|
||||
# every update that brings migrations. Written to DB_BACKUP_DIR with a
|
||||
# manifest beside it (row counts, key fingerprints, versions); with the
|
||||
# off-host bucket above set, each nightly dump is also uploaded under
|
||||
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
|
||||
# encrypted: they sit beside the database volume, in the same trust zone.
|
||||
# Restore: `docker compose run --rm db-restore latest`. See
|
||||
# docs/ops/disaster-recovery.md.
|
||||
#
|
||||
# Set "false" to stop the nightly dump.
|
||||
# DB_BACKUP_ENABLED="true"
|
||||
# Where dumps are written. The bundled compose file mounts the `backups`
|
||||
# volume here; change it only together with that mount. In a container a
|
||||
# directory that is not on a mount of its own sits in the container's layer
|
||||
# and is lost at the next update, so no dump is kept there.
|
||||
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
|
||||
# Five-field cron, in Europe/Berlin time like every scheduled job.
|
||||
# DB_BACKUP_SCHEDULE="30 1 * * *"
|
||||
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
|
||||
# besides.
|
||||
# DB_BACKUP_KEEP="7"
|
||||
# Set "false" to start an update without the dump before its migrations.
|
||||
# DB_BACKUP_BEFORE_MIGRATE="true"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stored backups in the database (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -446,6 +479,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 90 rather than repeat the default.
|
||||
# COACH_MESSAGE_RETENTION_DAYS="365"
|
||||
|
||||
# How long deleting an account, a managed profile or "all data" stays
|
||||
# undoable, in days. Default 14, ceiling 30. The account is locked at once
|
||||
# (signed out everywhere, sign-in refused, nothing runs for it) and an admin
|
||||
# can undo the deletion from the users list until the window ends; the purge
|
||||
# job erases it then, together with its stored and off-host copies. A wipe
|
||||
# keeps a copy of the record an admin can restore for the same time. 0 erases
|
||||
# immediately, as releases before 1.46 did. The Data & Privacy page states
|
||||
# this number, so it has to reach the container.
|
||||
# ACCOUNT_DELETION_GRACE_DAYS="14"
|
||||
|
||||
# How long the host CPU/memory samples behind the admin monitoring charts are
|
||||
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
|
||||
# where disk is the scarce resource.
|
||||
@@ -517,19 +560,43 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client-IP resolution -- optional (required behind Cloudflare)
|
||||
# -----------------------------------------------------------------------------
|
||||
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
|
||||
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
|
||||
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
|
||||
# address and the geo lookup resolves the wrong location. Set this to "1"
|
||||
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
|
||||
# it unset, or an attacker could forge the header. Off by default.
|
||||
# Number of trusted reverse-proxy hops in front of the app; the client IP is
|
||||
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
|
||||
# directly-exposed host, 1 (the default when unset) behind a single reverse
|
||||
# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the
|
||||
# headers, log one warning, and collapse anonymous callers into one
|
||||
# rate-limit bucket.
|
||||
# directly-exposed host (the connection's own address is used), 1 (the
|
||||
# default when unset) behind a single reverse proxy; X-Real-IP is read only
|
||||
# at 1. Forwarding headers are read only from a connection whose own address
|
||||
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
|
||||
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
|
||||
# wrong value makes the app refuse the headers, log one warning, and collapse
|
||||
# anonymous callers into one rate-limit bucket.
|
||||
# TRUST_PROXY_HOPS="1"
|
||||
#
|
||||
# The addresses your reverse proxy connects from, as comma-separated CIDRs
|
||||
# (IPv4 or IPv6; a bare address is one host). When set, only those
|
||||
# connections may report the client's address; every other connection is
|
||||
# its own client. When unset, any private address may, which is the old
|
||||
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
|
||||
# there every caller of the published port arrives from the gateway, a
|
||||
# private address, and could name its own client address. Set it whenever a
|
||||
# reverse proxy is in front:
|
||||
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
|
||||
# proxy container on a Docker network: that network's subnet (docker
|
||||
# network inspect <network>), e.g. 172.18.0.0/16
|
||||
# proxy on another machine with a public address: that address, /32
|
||||
# A proxy on a public address that is not listed is not believed: every
|
||||
# request through it shares the proxy's address and one rate-limit bucket.
|
||||
# The boot log names the rule in force in one [proxy-trust] line.
|
||||
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
|
||||
#
|
||||
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
|
||||
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
|
||||
# so the audit-log overview stores the CF datacentre address and the geo
|
||||
# lookup resolves the wrong location. "1" believes that header from EVERY
|
||||
# connection, before the checks above, so set it ONLY when the origin accepts
|
||||
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
|
||||
# admits only Cloudflare's address ranges). An origin that also answers on
|
||||
# its own public address lets any caller forge the address that rate limits
|
||||
# and the audit log record. Off by default.
|
||||
# TRUST_CF_CONNECTING_IP="1"
|
||||
|
||||
# Memory cap of the app container (read by docker-compose.yml, not by the app).
|
||||
|
||||
@@ -79,6 +79,8 @@ updates:
|
||||
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
|
||||
# have handed self-hosters a database their data directory cannot be
|
||||
# read by. Minor releases still arrive as PRs; a major is a deliberate
|
||||
# project decision with a migration guide behind it.
|
||||
# project decision with a migration guide behind it. The ignore also
|
||||
# keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
|
||||
# bump to 18 would leave it unable to start on the volume it moves.
|
||||
- dependency-name: "postgres"
|
||||
update-types: ["version-update:semver-major"]
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Compose database move
|
||||
|
||||
# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
|
||||
# containers: a v1.45 database with every migration applied, then the current
|
||||
# compose file's `db-upgrade` on its first start, a second start, a move cut
|
||||
# short, an emptied new volume and a new install. The service runs once on an
|
||||
# operator's host and has no second chance there, so every change to the
|
||||
# compose file or to the check runs it here first.
|
||||
# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- docker-compose.yml
|
||||
- scripts/compose-db-upgrade-check.sh
|
||||
- .github/workflows/compose-db-upgrade.yml
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- docker-compose.yml
|
||||
- scripts/compose-db-upgrade-check.sh
|
||||
- .github/workflows/compose-db-upgrade.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
move:
|
||||
name: Postgres 16 -> 18 move
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The check reads the v1.45 compose file from its tag.
|
||||
- name: Fetch the v1.45.0 tag
|
||||
run: git fetch --no-tags --depth=1 origin tag v1.45.0
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Move check
|
||||
run: bash scripts/compose-db-upgrade-check.sh
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Disaster recovery drill
|
||||
|
||||
# Rebuilds a lost server from the nightly database dump and the keys alone,
|
||||
# against real containers: the app image built from this checkout, the
|
||||
# bundled compose file with an S3-compatible bucket outside it, the demo seed
|
||||
# plus a Guardian with a managed profile, the dump taken and uploaded by the
|
||||
# scheduled job, every volume of the stack destroyed, a new stack, the
|
||||
# restore, and the app booted on the result (health, login, an encrypted
|
||||
# note, a document, the Guardian's profile, every table's row count against
|
||||
# the manifest). Two sources: from the bucket (`db-restore latest
|
||||
# --from-bucket`, the disaster path) and from dump files copied off the host
|
||||
# (`db-restore latest`).
|
||||
# `scripts/compose-dr-drill.sh` is the same drill, runnable locally; its
|
||||
# header lists what it checks and the mutations that must turn it red.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- docker-compose.yml
|
||||
- Dockerfile
|
||||
- docker-entrypoint.sh
|
||||
- scripts/compose-dr-drill.sh
|
||||
- scripts/dr-drill-seed.ts
|
||||
- scripts/db-dump.mjs
|
||||
- scripts/db-restore.mjs
|
||||
- scripts/pending-migrations.mjs
|
||||
- src/lib/database-backup/**
|
||||
- src/lib/jobs/database-backup-queue.ts
|
||||
- src/lib/jobs/reminder/backup-handlers.ts
|
||||
- .github/workflows/compose-dr-drill.yml
|
||||
schedule:
|
||||
# Nightly, 02:41 UTC: off the hour, after the evening merges.
|
||||
- cron: "41 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: compose-dr-drill-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
drill:
|
||||
name: Restore a lost server from its dump (${{ matrix.source }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
source: [bucket, local]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
# The seed scripts run from the checkout against the drill's database.
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
|
||||
- name: Drill
|
||||
env:
|
||||
DRILL_SOURCE: ${{ matrix.source }}
|
||||
run: bash scripts/compose-dr-drill.sh
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
name: e2e shard ${{ matrix.shard }}/3
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
# The WebKit spec runs in the `webkit` job below.
|
||||
# The WebKit specs run in the `webkit` job below.
|
||||
E2E_SKIP_WEBKIT: "1"
|
||||
|
||||
steps:
|
||||
@@ -146,8 +146,9 @@ jobs:
|
||||
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
|
||||
# same image as the shards.
|
||||
# The WebKit specs, in the same image as the shards: the bottom sheet on an
|
||||
# iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell
|
||||
# on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`).
|
||||
webkit:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
@@ -157,7 +158,7 @@ jobs:
|
||||
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
@@ -202,7 +203,7 @@ jobs:
|
||||
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
|
||||
|
||||
- run: pnpm exec playwright test --project=webkit-iphone
|
||||
- run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: integration shard ${{ matrix.shard }}/4 (shuffled)
|
||||
name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -110,6 +110,10 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
HEALTHLOG_INTEGRATION_WORKERS: "3"
|
||||
# Pull requests run the suite on Postgres 18, the compose default.
|
||||
# Installs that bring their own database may still be on 16, so the
|
||||
# nightly run keeps that major covered without doubling every PR.
|
||||
INTEGRATION_PG_IMAGE: "postgres:16-alpine"
|
||||
# Four times each property's own case count; a property's timeout
|
||||
# grows by the same factor (`propertyTimeout`).
|
||||
FC_RUNS_FACTOR: "4"
|
||||
|
||||
+430
@@ -1,5 +1,435 @@
|
||||
# Changelog
|
||||
|
||||
## [1.46.0] — 2026-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- **A database dump every night, and a restore that rebuilds a server.**
|
||||
Until now every copy the app made was per account and could only roll an
|
||||
account back on the same server; a lost server needed a dump you had to
|
||||
make yourself. The app now takes a whole-database dump every night (01:30
|
||||
Europe/Berlin) and before every update that brings migrations, keeps the
|
||||
last seven on the new `backups` volume, and also puts it in your off-host
|
||||
bucket when one is set up. Each dump has a manifest beside it: app
|
||||
version, migration, Postgres version, key fingerprints and the row count
|
||||
of every table. `docker compose run --rm db-restore latest` (or
|
||||
`latest --from-bucket` after losing the host) restores it into an empty
|
||||
database only, checks the keys and the checksum first and every table's
|
||||
row count after, and refuses anything else. A drill in CI does exactly
|
||||
that every night and on every change to the backup path: it builds a
|
||||
server, fills it, destroys every
|
||||
volume, rebuilds it from the bucket and the keys alone, and checks that
|
||||
people can sign in and read their notes and documents.
|
||||
- **Recovery readiness, pushed to the admins.** One card on the admin
|
||||
dashboard and the backups page says whether a server could be recovered
|
||||
today: the nightly dump, the weekly account copies, the off-host copies,
|
||||
the restore drill, whether off-host copies are set up, and the recovery
|
||||
kit. When one of them goes wrong, every admin gets a notification, once,
|
||||
and again daily while it stays broken, and once when it recovers. A
|
||||
stopped backup used to show only if someone opened the page.
|
||||
- **A recovery kit instead of "save the active key".** The kit lists every
|
||||
key a copy still needs, retired ones included, with their fingerprints:
|
||||
the encryption keys, the backup key and its predecessors, and the token
|
||||
key. It becomes due again whenever that set changes, for example after a
|
||||
key rotation. An upgraded server asks once, because the kit now also
|
||||
lists the token key.
|
||||
- **Fourteen days to change your mind.** Deleting an account or a managed
|
||||
profile now locks it at once (every session, token, share link and
|
||||
connection is revoked) and erases it after 14 days
|
||||
(`ACCOUNT_DELETION_GRACE_DAYS`, 0 keeps the old immediate deletion). An
|
||||
admin can undo it in the users list until then. "Delete all data" first
|
||||
keeps a copy of the account for the same 14 days, and the copy restores
|
||||
like any other. Nothing in the background touches an account while it
|
||||
waits.
|
||||
- **Apple Health write-back for measurement tokens.** A measurement token,
|
||||
the narrow kind a scale or Home Assistant uses to add readings, now has a
|
||||
"Write readings to Apple Health" switch, when you create it and in the
|
||||
token list. With it on, the HealthLog app on your iPhone adds that
|
||||
token's readings to Apple Health. The readings still show that they came
|
||||
from an external device, and the token still cannot read or change
|
||||
anything. The server decides which readings go to Apple Health and tells
|
||||
the app, so switching it on later also covers what the token already
|
||||
sent. Readings from before this release are matched to a token only
|
||||
where the account has exactly one measurement token; the card says so
|
||||
when older readings could not be matched. (#1272)
|
||||
- **Recently deleted for documents.** While it holds anything, the
|
||||
Documents page links to Recently deleted. It lists each deleted document
|
||||
with its type, its date, where it came from (Paperless-ngx, for
|
||||
example) and how many of its 30 days are left, and each one can be
|
||||
restored or deleted for good right away. The import picker now lets you
|
||||
tick a document you deleted: one still in Recently deleted comes back
|
||||
with everything that belonged to it, one already removed is imported
|
||||
again. Automatic imports, such as a Paperless workflow or the import
|
||||
script, still never bring back a document you deleted. (discussion
|
||||
#1275)
|
||||
- **Questionnaires in every app language.** PHQ-9, GAD-7, WHO-5 and the
|
||||
sleep questionnaire no longer fall back to English where no validated
|
||||
translation exists. New validated versions: PHQ-9 in French, Dutch,
|
||||
Korean and Brazilian Portuguese, GAD-7 in Dutch, Korean and Brazilian
|
||||
Portuguese, WHO-5 in Dutch and Korean, and the sleep questionnaire in
|
||||
Polish. Elsewhere HealthLog shows its own faithful translation with a
|
||||
note on every step that the result is for orientation, and one tap
|
||||
switches to the English original. Each check-in remembers which wording
|
||||
it was answered in, and the doctor report marks a total from an
|
||||
unvalidated translation with an asterisk.
|
||||
- **Your own files import in full.** The JSON import reads every field
|
||||
the full backup and the JSON export write: a night's sleep stages, range
|
||||
readings, an ECG's verdict, the recording device, and a mood entry's
|
||||
note, detailed ratings, time zone, tags, rated factors and day context.
|
||||
A full backup also brings your own mood tags, factors and groups along.
|
||||
Missing ones are created within the usual limits, a tag you already
|
||||
have is never overwritten, and anything that cannot be matched is named
|
||||
on the import card.
|
||||
- **GPT-6 models.** gpt-6-sol, gpt-6-luna and gpt-6.1-sol get reasoning
|
||||
control with a real "off", the request shape reasoning models expect,
|
||||
and image input, through an OpenAI key and the ChatGPT sign-in alike.
|
||||
The OpenAI presets offer gpt-6.1-sol and gpt-6-luna.
|
||||
- **A queued restore says what it is waiting for.** A restore never runs
|
||||
beside a backup or another large job. The admin console now says which
|
||||
of the two it waits for and since when, instead of reading like a stuck
|
||||
worker.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Postgres 18.** The compose file now runs Postgres 18 instead of 16. An
|
||||
install from 1.45 or earlier moves its database across by itself on the
|
||||
first start: a short-lived `db-upgrade` step copies it into a new
|
||||
`postgres-data` volume, compares the row count of every table, gathers
|
||||
the planner statistics, and only then lets the app start. If anything
|
||||
fails, the app does not start and the log says why; a move cut short is
|
||||
run again from scratch on the next start. The old `pgdata` volume is
|
||||
left as it was, so going back is the previous image and two lines of the
|
||||
compose file. On a real 600 MB instance the move took eleven seconds.
|
||||
HealthLog keeps running on Postgres 16 for installs that bring their own
|
||||
database, and the tests run on both. (discussion #1282)
|
||||
- **A quicker, lighter start.** On an account with ten years of data the
|
||||
app answers its first request about a tenth sooner after a start, holds
|
||||
about 55 MB less memory right after it, and the background worker is ready
|
||||
after loading about a quarter of the code it used to need, with the rest
|
||||
loaded in the background. Within a minute it has tried every job once and
|
||||
reports any that cannot load, so a broken image shows up at deploy time
|
||||
instead of when a job fails hours later. Pages and charts answer as fast
|
||||
as in 1.45, with the same number of database reads; a ten-year chart takes
|
||||
a few milliseconds longer because it now checks that a trend has enough
|
||||
days behind it.
|
||||
- **Chart axes that read cleanly.** Value ticks fall on round steps (60,
|
||||
80, 100 rather than 57, 83, 109), date ticks fall on a calendar grid
|
||||
(days, Mondays, months, quarters, years) and keep their place as data
|
||||
arrives, and the "your average" and target zone labels sit beside the
|
||||
plot instead of over the readings or clipped to one letter. The dots
|
||||
under a chart mark only the days the chart itself shows.
|
||||
- **Tidier code underneath.** Large parts of the code were split up so
|
||||
the next changes are smaller and safer to make: files over 800 lines
|
||||
went from 123 to 84, functions over 200 lines from 411 to 346. Nothing
|
||||
you use should look different. Signing in with a password or a passkey,
|
||||
adding a weight, logging a mood, importing lab results, changing a
|
||||
setting and restoring a full backup are now tested end to end against a
|
||||
real server.
|
||||
- **TrueNAS guide.** It covers the app in the TrueNAS Community train and
|
||||
its backups, and the encryption key backup step points at the app's
|
||||
Encryption Key field.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Slow local models get the time you allow them.** The response timeout
|
||||
reached each model call, but not the work around it: a daily briefing is
|
||||
up to five calls in a row and had one fixed bound of the timeout plus 30
|
||||
seconds, so a model needing several minutes per answer was cut off in
|
||||
its second call. The briefing now gets your timeout for every call, the
|
||||
Insights page keeps waiting as long as that allows, and the status cards
|
||||
and the other AI background jobs follow the same setting instead of
|
||||
fixed limits. (#1274)
|
||||
- **The Coach no longer shows a model's reasoning as its answer.** With a
|
||||
model that always reasons, served through an OpenAI-compatible gateway
|
||||
or a local server, the Coach could show the thought instead of the
|
||||
reply, cut off, and sometimes skip the tools it meant to call. The token
|
||||
limit now keeps room for reasoning whatever the model is asked,
|
||||
reasoning is taken out of the answer in every shape servers send it,
|
||||
tool calls run whenever the reply carries them, and a model that still
|
||||
produces nothing but reasoning gets its own message suggesting another
|
||||
model. A model that does not reason keeps its own answer even when it
|
||||
is cut off. (#1276)
|
||||
- **A model that never answers can no longer run up requests.** One idle
|
||||
hour against a model that reasoned into its token limit could cost well
|
||||
over a thousand requests. One call now makes two requests at most. A
|
||||
reply with reasoning and no answer is asked once more without
|
||||
reasoning, and if that does not help either, that model is not asked
|
||||
that way again for an hour. Background work leaves a provider that
|
||||
keeps failing alone: after three failures in a row it waits five
|
||||
minutes, from five on it pauses for half an hour, doubling while the
|
||||
failures continue, up to a day. The Coach and "Re-run analysis" still
|
||||
reach it and show its error, and saving your AI settings, the admin AI
|
||||
settings or a fresh ChatGPT sign-in ends the pause. A status card whose
|
||||
generation failed waits from five minutes up to two hours before the
|
||||
next try, even when new data arrives. Tokens spent on replies without
|
||||
an answer count toward the daily AI budget, "Index all documents" stops
|
||||
after three failed calls in a row, and a gateway that refuses the
|
||||
reasoning setting is noticed once instead of failing every call.
|
||||
(#1279)
|
||||
- **"Extract lab values" reads long documents.** On a stored document it
|
||||
still sent the whole report in one call, which a long report outgrew,
|
||||
and the answer came back as "Couldn't read the document. Try a clearer
|
||||
copy." It now reads the way the lab scan does: a PDF page by page and
|
||||
text in parts, up to 400 readings, with the same timeout and budget. If
|
||||
the provider stops early, the readings already found are kept and the
|
||||
review warns that some values may be missing, every time it is opened;
|
||||
a read that stopped before the first value says so instead of blaming
|
||||
the copy. An extraction the iPhone app waits for reads in at most three
|
||||
parts and marks the document incomplete when that was not enough.
|
||||
(#1257)
|
||||
- **PDF previews, vision reads and search work together.** The server
|
||||
loaded two versions of pdf.js, and whichever ran first broke the other:
|
||||
after a local text read every preview failed and a vision read of a PDF
|
||||
was refused, after a preview the local text read failed and the document
|
||||
stayed out of search. The server now ships one pdf.js. Previews and
|
||||
search entries the clash broke are repaired once on the first start
|
||||
after the update. (#1277, PR #1278)
|
||||
- **"End" on a medication works, and ends it now.** The button had failed
|
||||
with an error on every press since it was added. It now ends the
|
||||
running course at that moment: a dose taken earlier today still counts,
|
||||
and a later one is no longer due, reminded or counted as missed. End is
|
||||
offered only while a course is running, someone who manages your record
|
||||
can use it too, undoing the end brings the later doses back, and a new
|
||||
course can start from the next day. (#1280, PR #1281)
|
||||
- **Memory stays flat under bursts of requests.** Every page the server
|
||||
rendered stayed in memory for ten minutes after it was sent, about
|
||||
250 KB each, so a run of automated requests could push the container
|
||||
towards its memory limit. A render is now released with its request.
|
||||
- **Fast right after an import, not only once the database caught up.**
|
||||
Postgres learns what a table holds in the background, some time after
|
||||
rows arrive. Until it had, on a new install or right after a large
|
||||
import, several reads chose a plan that grew with the square of the
|
||||
data: the analytics page over a few thousand blood pressure readings
|
||||
took seconds, and the long-range charts and all-time highs and lows of
|
||||
a ten-year history could run past the database's time limit. Those
|
||||
reads now take one pass over the readings whatever Postgres knows, with
|
||||
the same results, and take a fraction of a second.
|
||||
- **Trends need readings on enough days.** Three blood pressure readings
|
||||
taken in one sitting, the only ones that week, produced a 7-day trend of
|
||||
thousands of mmHg. A trend is now shown only when the readings fall on
|
||||
at least three days spread over at least half the window (4 days of 7,
|
||||
15 of 30), wherever a trend appears.
|
||||
- **iPad and tablets.** The app fills the window after a rotation and
|
||||
under Safari's floating toolbars, without a strip of page colour at the
|
||||
bottom, a cut-off last card or a page that scrolls under the app. In
|
||||
landscape every sidebar destination is reachable, with a cue when more
|
||||
are below. A vertical swipe over a row of tabs scrolls the page, and the
|
||||
area over the home indicator matches the page in Edge, Chrome and
|
||||
Firefox.
|
||||
- **The app's own backup and export import again.** The JSON import
|
||||
refused both, because they write an empty field as `null` or `""`. It
|
||||
reads them as the app wrote them now, converts display units such as lb
|
||||
or mmol/L, and skips deletion records. The portable backup also carries
|
||||
a reading's glucose context, sleep stage, range, rhythm verdict and
|
||||
device, which it used to drop.
|
||||
- **Deleted imports stay deleted after a restore.** A backup restored onto
|
||||
a fresh server let a Paperless workflow or the import script store again
|
||||
every document you had deleted. The backup now carries which imports
|
||||
were deleted.
|
||||
- **ChatGPT sign-in works without a model override.** The ChatGPT backend
|
||||
rejects every model from gpt-5.5 down, so an install without its own
|
||||
model list ended in "all configured Codex slugs were rejected". The
|
||||
default list now starts at gpt-5.6-terra.
|
||||
- **Workout splits and GPX times.** Splits were missing for every synced
|
||||
route, and an exported GPX track had no times. Both read the
|
||||
stored route points correctly now.
|
||||
- **A hung backup no longer blocks restores.** A backup that hung held the
|
||||
slot for large jobs until the container restarted, so a restore waited
|
||||
forever. A job that outlives its time limit now gives the slot up.
|
||||
- **API answers match the published contract.** Creating or editing a
|
||||
medication answers with the same fields as reading it, a dose answers
|
||||
the full entry, medications and doses come back in the same order on
|
||||
every request, readings opened by day carry their note, and the
|
||||
contract now declares several answers the server already gave.
|
||||
- Every page view saved your language to the account again and counted
|
||||
against your write limit; it is saved once per tab now.
|
||||
- A page reloaded right after you entered a reading shows that reading
|
||||
instead of the dashboard from before.
|
||||
- The daily briefing's chart notes and storyline were dropped from replies
|
||||
that passed validation; they are kept now.
|
||||
- A stored AI key that can no longer be decrypted counts as no key instead
|
||||
of failing the briefing with a server error.
|
||||
- After a Fitbit or Google Health history import, the dashboard and
|
||||
analytics show the new data right away instead of after their cache ran
|
||||
out.
|
||||
- Averages and streaks take one clock per request, so a reading at the
|
||||
edge of a window no longer moves in and out, and today's reading counts
|
||||
for the streak whatever time zone the database runs in.
|
||||
- A refused request by someone you share your record with shows in your
|
||||
activity feed with its own wording.
|
||||
- A stat's value and unit stay on one line beside the docked day view;
|
||||
the neutral score label is readable in the dark theme; axis labels are
|
||||
sized correctly when reduced motion is on.
|
||||
|
||||
- **Large backups upload without filling the memory.** Uploading a copy
|
||||
with many documents or ECG recordings held them whole while it was
|
||||
checked, and a big vault could crash the server. Every reader of a
|
||||
backup now streams the large parts; a 271 MB file needs about 2 MB.
|
||||
- **Backup texts say what each copy can do.** The weekly copy is an
|
||||
account rollback on this server, the personal export is for the person,
|
||||
the JSON import reads measurements and mood within its stated limits, and
|
||||
only the database dump rebuilds a server. Nothing claims more anymore.
|
||||
- **Long Coach answers keep all their steps.** After a reload an answer
|
||||
with more than 48 steps lost the rest.
|
||||
- **The hint for reading scans on the device points to the right switch**,
|
||||
Settings, Modules, Labs, in every language.
|
||||
- **Help links open the right page** instead of going through redirects.
|
||||
- **A directory setting below `/proc` no longer freezes the server.** Node's
|
||||
way of creating a folder with its parents loops forever there on Linux,
|
||||
so a `DB_BACKUP_DIR` or `GEOLITE2_DIR` pointing below it stopped the
|
||||
process. Such a directory now reads as not writable.
|
||||
- **The last hour of a long DST day keeps its doses.** On the day the
|
||||
clocks go back, the list of today's doses that the apps offer from ended
|
||||
an hour early, so a late evening dose was missing from it; on the day
|
||||
they go forward it reached an hour into tomorrow. It now spans the real
|
||||
local day, as the dashboard already did.
|
||||
- **A dose logged for a two-day plan lands on its slot in the week the
|
||||
clocks go back.** A plan with doses on two neighbouring weekdays, started
|
||||
shortly before the change, could file a later dose as unscheduled, and
|
||||
logging it again gave back the previous one. Whether a plan counts as
|
||||
daily or weekly no longer depends on the stretch of days it is read
|
||||
through.
|
||||
|
||||
### Security
|
||||
|
||||
- **A crafted PDF or image can no longer take the server down.** A small
|
||||
PDF with one huge compressed image, or a JPEG with a padded header, made
|
||||
the server reserve gigabytes and get killed, for every account on it.
|
||||
Everything pdf.js does now runs in its own process with a memory and a
|
||||
time limit, image sizes are read by the JPEG rules and an unreadable size
|
||||
is refused, and a document over the limits is marked as such instead of
|
||||
being tried again.
|
||||
- **Rate limits count the real caller.** Forwarding headers are believed
|
||||
only from a connection that comes from the server's own network or a
|
||||
proxy on it; a caller reaching the port directly no longer picks its own
|
||||
address, and the sign-in throttle no longer exempts an address an account
|
||||
used before.
|
||||
- **Writes from other sites are refused.** A request that changes something
|
||||
and comes from another site, including a sibling subdomain, is refused
|
||||
before it is read. The iPhone app and other token clients are not
|
||||
affected.
|
||||
- **The MCP consent page names where the access goes**, and warns when a
|
||||
self-registered application sends it to a remote host.
|
||||
- **Switching Labs off keeps lab values out of every AI feature.** The
|
||||
Coach, the daily briefing, status notes and the MCP lab prompt read no
|
||||
lab values while the module is off. Before, the Coach still sent them.
|
||||
- Smaller fixes: share links show no documents while the documents module
|
||||
is off, a narrow medication token can no longer probe medication names,
|
||||
MCP tools fence medication and lab text like every other user text,
|
||||
reading documents takes turns between accounts, so one account's heavy
|
||||
files cannot hold up everyone else's, the session cookie cannot be
|
||||
planted from a sibling subdomain over HTTPS, file-like paths under `/api`
|
||||
go through the request checks, the deploy
|
||||
webhook can be signed, the error relay to GlitchTip needs a signed-in
|
||||
session, and database error details are redacted before they leave.
|
||||
- Raised the minimum versions of body-parser and fast-uri, which the MCP
|
||||
server runs on (CVE-2026-111181, high; CVE-2026-110180;
|
||||
CVE-2026-110306), of ip-address, which took exponential time on a
|
||||
crafted IPv6 zone identifier, and of a development-only copy of undici.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Migrations 0475 to 0485 (0475, 0476, 0477, 0478, 0479, 0480, 0482, 0483
|
||||
and 0485; 0481 and 0484 are not used). Most add a column under a
|
||||
bounded lock wait and retry on a lock timeout. These do more:
|
||||
- 0476 links existing readings to the measurement token that sent them
|
||||
where the account has exactly one such token. It sets only that link,
|
||||
so the readings do not count as changed.
|
||||
- 0477 builds an index on `measurements` concurrently; writes carry on
|
||||
during the build.
|
||||
- 0478 clears the recorded preview failures and 0479 re-queues the PDFs
|
||||
whose search entry failed, so the first start renders those previews
|
||||
once more and indexes those PDFs once more. The indexing uses your AI
|
||||
provider where one is set up and you have allowed it, as an upload
|
||||
would.
|
||||
- 0485 drops the unused preview-features column from `users`.
|
||||
On a copy of a real instance all of them together took under a second.
|
||||
- If a concurrent index build fails half-way, for example because the
|
||||
container was stopped, mend the cause and mark the migration rolled
|
||||
back; the next start drops the invalid index and builds it again. See
|
||||
`docs/ops/deploy.md`.
|
||||
- After a deploy, the worker reports any job it cannot load within a
|
||||
minute; `docs/ops/deploy.md` says what to look for and when to roll
|
||||
back.
|
||||
- `/api/settings/preview-features` is retired and answers 410. It served
|
||||
a switch for pages that never shipped, and no client reads it. A backup
|
||||
from 1.44 or 1.45 that carries the field still restores.
|
||||
- The Apple Health switch takes effect with an iPhone app build that
|
||||
reads it; until then the app keeps mirroring Withings, imported and
|
||||
hand-entered readings as before.
|
||||
- If you set `CODEX_MODEL_FALLBACK_CHAIN` only to get past the rejected
|
||||
ChatGPT models, you can remove it.
|
||||
- Migrations 0486 to 0489 add the dump ledger and its key column, the
|
||||
readiness and kit tables and the deletion columns. 0487 also drops the four old key-backup
|
||||
columns from `app_settings` and carries an existing confirmation over;
|
||||
as with 0485, do not run the previous image beside the new schema.
|
||||
- **A new `backups` volume** holds the local dumps. The compose file
|
||||
mounts it; a stack built without it keeps dumps off-host only and says
|
||||
so. In the bucket, dumps go under `database/<your host>/`, so two
|
||||
servers sharing a bucket never mix them up; add `database/` to the
|
||||
bucket's lifecycle rule, and the backups page warns when no rule covers
|
||||
it. Dumps keep a deleted account until they
|
||||
age out, so state that period as your backup retention.
|
||||
- **New settings:** `DB_BACKUP_ENABLED`, `DB_BACKUP_DIR`,
|
||||
`DB_BACKUP_SCHEDULE`, `DB_BACKUP_KEEP`, `DB_BACKUP_BEFORE_MIGRATE` and
|
||||
`ACCOUNT_DELETION_GRACE_DAYS`, all on sensible defaults. If the dump
|
||||
before a migration fails on a writable directory, the start stops and
|
||||
names the switch to skip it.
|
||||
- **Behind a reverse proxy,** forwarding headers are believed only from a
|
||||
private or loopback address. If your proxy reaches HealthLog from a
|
||||
public address, set `TRUSTED_PROXY_CIDRS` to that address; otherwise
|
||||
every user shares one sign-in rate limit. `TRUSTED_PROXY_CIDRS` also
|
||||
narrows the default when you know your proxy's network
|
||||
(`docs/self-hosting/reverse-proxy.md`). Behind a proxy that rewrites the
|
||||
Host header, make sure `APP_URL` is right, or writes from the browser
|
||||
are refused as cross-site.
|
||||
- The deploy webhook can now be signed (`X-Deploy-Webhook-Signature`);
|
||||
`DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only.
|
||||
`DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP` keeps its meaning, and the stock
|
||||
Coolify setup needs no change.
|
||||
- Over HTTPS the session cookie is now `__Host-healthlog_session`. The old
|
||||
name is still read and swapped on the next request, so nobody is signed
|
||||
out.
|
||||
- The app image is about 6 MB larger: it now carries the Postgres 18
|
||||
client for the dumps. The supported memory floor stays at 1536 MB for
|
||||
the app container.
|
||||
- The first start with the new compose file moves the bundled database to
|
||||
Postgres 18 (see Changed). Use `docker compose up -d` with the new
|
||||
compose file; pulling only the new image keeps Postgres 16, which this
|
||||
release still runs on. The move needs free disk space about the size of
|
||||
the database. Rollback, the steps for a database you run yourself, and
|
||||
how to remove the old volume later: `docs/ops/postgres-major-upgrade.md`.
|
||||
|
||||
### Thanks
|
||||
|
||||
Thanks to @haritowa for two reports that came with their fixes: the clash
|
||||
between two pdf.js versions that broke previews and search (#1277), fixed
|
||||
in PR #1278, and the "End" button that never worked (#1280), fixed in PR
|
||||
#1281. Both pull requests are part of this release as written.
|
||||
|
||||
Thanks to @Tio61 for the precise wire captures behind the Coach showing a
|
||||
model's reasoning as its answer (#1276) and for the exact request counts
|
||||
that exposed the runaway requests against a model that never answers
|
||||
(#1279).
|
||||
|
||||
Thanks to @gesus14 for noticing that the daily briefing gave up long
|
||||
before the response timeout allowed (#1274).
|
||||
|
||||
Thanks to @asmala for coming back to report that "Extract lab values" on
|
||||
a stored document still failed on long reports (#1257).
|
||||
|
||||
Thanks to @cnrd for the careful proposal that became the Apple Health
|
||||
switch for measurement tokens (#1272).
|
||||
|
||||
Thanks to @frubino for asking how to get a deleted document back, which
|
||||
led to Recently deleted (discussion #1275).
|
||||
|
||||
Thanks to @muhdusama for noticing that the MCP labs tool declared a
|
||||
narrower answer than it gives, and for the fix with its tests (PR #1284).
|
||||
|
||||
Thanks to @sttrebo for asking about Postgres 18, which turned into the
|
||||
move itself, not just a guide for it (discussion #1282).
|
||||
|
||||
## [1.45.0] — 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+12
-3
@@ -151,18 +151,27 @@ What a reviewer checks on a translation pull request:
|
||||
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
|
||||
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
|
||||
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
|
||||
- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce.
|
||||
- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section.
|
||||
|
||||
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
|
||||
|
||||
### Questionnaire wording
|
||||
|
||||
The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording/<instrument>/<locale>.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is:
|
||||
|
||||
- `validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint.
|
||||
- `validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only).
|
||||
|
||||
To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`.
|
||||
|
||||
### Adding a new locale
|
||||
|
||||
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
|
||||
|
||||
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
|
||||
2. Copy `messages/en.json` to `messages/<locale>.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys).
|
||||
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
|
||||
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
|
||||
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR.
|
||||
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
|
||||
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
|
||||
|
||||
### Style guide for translators
|
||||
|
||||
+41
-11
@@ -77,7 +77,13 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb
|
||||
# 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).
|
||||
RUN apk add --no-cache tzdata && apk upgrade --no-cache
|
||||
#
|
||||
# `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
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -111,6 +117,15 @@ ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 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
|
||||
# empty named volume from the image directory it covers, ownership included.
|
||||
# Without a mount the directory stays in the container's layer; the app reads
|
||||
# its mount table, sees that, and keeps no dump here (dump-core.mjs).
|
||||
RUN mkdir -p /var/lib/healthlog/backups && \
|
||||
chown nextjs:nodejs /var/lib/healthlog/backups && \
|
||||
chmod 700 /var/lib/healthlog/backups
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
# The server reads its message catalogs from here on first use, one locale at
|
||||
@@ -154,7 +169,7 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
|
||||
# before the platform package, so it decides which binary runs.
|
||||
#
|
||||
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
|
||||
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
|
||||
# store once held a second, older canvas that a second pdfjs-dist pulled in,
|
||||
# and this step used to take whichever `find | head -1` returned first. It
|
||||
# returned the old binary, which then sat next to the new loader: rendering
|
||||
# still worked, but `loadImage` called an `Image.decode` the old binary does
|
||||
@@ -190,13 +205,15 @@ RUN set -e; \
|
||||
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
|
||||
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
|
||||
#
|
||||
# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the
|
||||
# store — `pdf-parse` carries its own, older copy for the text-extraction path —
|
||||
# and this step used to take whatever `find | head -1` returned first. That is
|
||||
# readdir order, so it could hoist the OLDER copy and the version named in
|
||||
# package.json would never run in the image: the renderer resolving one version
|
||||
# while every local check exercised another. Name the version and fail the build
|
||||
# when it is absent, instead of degrading to whichever copy comes back first.
|
||||
# Hoist the version the app pins, by name. More than one pdfjs-dist once reached
|
||||
# the store (a text-extraction dependency carried its own, older copy), and this
|
||||
# step used to take whatever `find | head -1` returned first. That is readdir
|
||||
# order, so it could hoist the OLDER copy and the version named in package.json
|
||||
# would never run in the image: the renderer resolving one version while every
|
||||
# local check exercised another. Name the version and fail the build when it is
|
||||
# absent, instead of degrading to whichever copy comes back first. Two copies in
|
||||
# one process also break each other (#1277); the lockfile now resolves one, and
|
||||
# `src/__tests__/single-pdfjs-guard.test.ts` keeps it that way.
|
||||
COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version
|
||||
RUN set -e; \
|
||||
PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \
|
||||
@@ -231,11 +248,16 @@ COPY --from=builder /app/src/generated ./src/generated
|
||||
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
|
||||
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
|
||||
# pending migration count, the second runs `migrate deploy` with its
|
||||
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
|
||||
# rewrite them.
|
||||
# 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.
|
||||
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/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
|
||||
# Prove the reset CLI's imports resolve in this tree: with every import found
|
||||
@@ -245,6 +267,14 @@ RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
|
||||
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
|
||||
node --input-type=module -e "await import('pg')" && \
|
||||
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
|
||||
# The dump and restore scripts load, the client tools they run are the
|
||||
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
|
||||
# loads resolves from the shared module's place in the tree.
|
||||
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
|
||||
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
|
||||
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: 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
|
||||
|
||||
@@ -50,7 +50,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
|
||||
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
|
||||
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
|
||||
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
|
||||
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
|
||||
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
|
||||
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
|
||||
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
|
||||
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
|
||||
@@ -85,11 +85,11 @@ Verify the running version:
|
||||
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
|
||||
```
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
|
||||
|
||||
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
|
||||
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
|
||||
|
||||
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
|
||||
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@@ -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 16 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 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).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"/custom-metrics/[id]/page": 303,
|
||||
"/custom-metrics/[id]/values/page": 311,
|
||||
"/cycle/page": 412,
|
||||
"/documents/deleted/page": 291,
|
||||
"/documents/page": 378,
|
||||
"/enroll-mfa/page": 305,
|
||||
"/illness/[id]/page": 447,
|
||||
|
||||
+266
-23
@@ -1,4 +1,4 @@
|
||||
# HealthLog — two-container stack (app + Postgres 16).
|
||||
# HealthLog — two-container stack (app + Postgres 18).
|
||||
#
|
||||
# First run (clean host):
|
||||
# cp .env.example .env
|
||||
@@ -14,9 +14,13 @@
|
||||
# and login silently fails. This is the single most common self-host snag.
|
||||
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
|
||||
#
|
||||
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
|
||||
# data dir). The app container is stateless, so a backup of the database is
|
||||
# a backup of everything. The optional GeoLite2 databases are either baked
|
||||
# Persistent state lives in the named volume `postgres-data` (Postgres' data).
|
||||
# The app container is stateless, so a backup of the database is a backup of
|
||||
# everything. `backups` holds the database dumps the app takes every night and
|
||||
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
|
||||
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
|
||||
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
|
||||
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
|
||||
# into the image at build time or mounted read-only from the host and
|
||||
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
|
||||
|
||||
@@ -88,8 +92,10 @@ x-healthlog-env: &healthlog_env
|
||||
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
# default — a direct-exposed host must not trust a forgeable header).
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
|
||||
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
|
||||
# ranges): the header is believed from every connection (off by default;
|
||||
# see docs/self-hosting/reverse-proxy.md).
|
||||
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
|
||||
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
|
||||
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
|
||||
@@ -102,7 +108,14 @@ x-healthlog-env: &healthlog_env
|
||||
# collapses every anonymous caller into one rate-limit bucket. Listed here
|
||||
# so an operator value in .env reaches the container (the `environment:`
|
||||
# block is a whitelist).
|
||||
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
|
||||
# (comma-separated CIDRs). Set, only those connections may report a client
|
||||
# address; unset, any private address may, which behind Docker Desktop,
|
||||
# OrbStack or docker-proxy includes every caller of the published port.
|
||||
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
|
||||
# See docs/self-hosting/reverse-proxy.md.
|
||||
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
|
||||
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
|
||||
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
||||
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
||||
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
||||
@@ -145,6 +158,20 @@ x-healthlog-env: &healthlog_env
|
||||
# serves anyway. A last resort for a refusal you are certain is false: it
|
||||
# disables a safety check. See docs/self-hosting/getting-started.md.
|
||||
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
|
||||
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
|
||||
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
|
||||
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
|
||||
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
|
||||
# and the last two taken before a migration. With the off-host bucket set,
|
||||
# each nightly dump is also uploaded under `database/`, sealed with
|
||||
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
|
||||
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
|
||||
# DB_BACKUP_DIR only together with the volume mount.
|
||||
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
|
||||
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
|
||||
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
|
||||
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
|
||||
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
@@ -166,6 +193,7 @@ x-healthlog-env: &healthlog_env
|
||||
# the block exists: `environment:` is a whitelist, and a value in .env that
|
||||
# is not named here never arrives.
|
||||
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
|
||||
ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}"
|
||||
# How long Coach conversations are kept, in days (default 365, floor 30).
|
||||
# Like the audit window this number is PUBLISHED to the user on the Data &
|
||||
# Privacy page, so an instance running 90 has to be able to say 90.
|
||||
@@ -278,17 +306,23 @@ x-healthlog-env: &healthlog_env
|
||||
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
||||
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
||||
OIDC_ONLY: "${OIDC_ONLY:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
# Deploy-result webhook (optional). POST /api/internal/deploy-webhook
|
||||
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
|
||||
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
|
||||
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
|
||||
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
|
||||
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a
|
||||
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
|
||||
# one setting that stops a replay. DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure
|
||||
# notifications. Listed here so operator values in .env reach the
|
||||
# container (the `environment:` block is a whitelist). See
|
||||
# docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# Optional replay hardening: when "true", the deploy webhook
|
||||
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
||||
# See docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
@@ -473,6 +507,11 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
<<: *healthlog_env
|
||||
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
|
||||
# copies its nightly dump off-host when the bucket is set, but keeps none
|
||||
# here and takes none before a migration; the boot log says so.
|
||||
volumes:
|
||||
- backups:/var/lib/healthlog/backups
|
||||
# Bring your own GeoLite2 databases. The published image only carries
|
||||
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
|
||||
# offline tier is usually absent and every login IP goes to the online
|
||||
@@ -482,13 +521,15 @@ services:
|
||||
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
|
||||
# once uncommented. Mounting at a different container path also works,
|
||||
# just also set GEOLITE2_DIR in .env to match it.
|
||||
# Steps: docs/self-hosting/geolite2.md.
|
||||
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
|
||||
# above, beside the backups line:
|
||||
#
|
||||
# volumes:
|
||||
# - ./geolite2:/opt/geolite2:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-upgrade:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
# Bounded log rotation: the default json-file driver keeps every line
|
||||
# forever, which is exactly what fills the disk on the small NAS /
|
||||
@@ -559,9 +600,14 @@ services:
|
||||
# environment:
|
||||
# <<: *healthlog_env
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# # The worker takes the nightly database dump, so it needs the volume.
|
||||
# volumes:
|
||||
# - backups:/var/lib/healthlog/backups
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# db-upgrade:
|
||||
# condition: service_completed_successfully
|
||||
# restart: unless-stopped
|
||||
# logging:
|
||||
# driver: json-file
|
||||
@@ -572,12 +618,11 @@ services:
|
||||
# stop_grace_period: 45s
|
||||
|
||||
db:
|
||||
# Pinned to the concrete minor instead of the floating `16-alpine`
|
||||
# Pinned to the concrete minor instead of the floating `18-alpine`
|
||||
# alias so a database update is always a deliberate, reviewable
|
||||
# change (at pin time `16-alpine` resolved to this same digest, so
|
||||
# existing deployments see no image change). Minor releases share
|
||||
# the on-disk format — bumping this pin is safe without a dump /
|
||||
# restore; a major-version bump (17+) is not.
|
||||
# change. Minor releases share the on-disk format, so bumping this pin
|
||||
# is safe without a dump / restore; a major version is not, which is
|
||||
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
|
||||
#
|
||||
# The pin only works as long as something watches it: PostgreSQL
|
||||
# publishes minor releases with security and data-corruption fixes and
|
||||
@@ -587,7 +632,7 @@ services:
|
||||
# `.github/dependabot.yml` now raises a PR for each new minor;
|
||||
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
|
||||
# (concrete pin, active watch) from being dropped.
|
||||
image: postgres:16.15-alpine
|
||||
image: postgres:18.6-alpine
|
||||
container_name: healthlog-db
|
||||
# Loads pg_stat_statements so slow queries can be found from measurements
|
||||
# instead of guesses. Loading the library only collects statistics; the
|
||||
@@ -617,8 +662,12 @@ services:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
|
||||
# and expects the volume one level up, so the next major can sit beside
|
||||
# this one in the same volume. Mounting it at .../data, as 16 had it,
|
||||
# makes the container refuse to start.
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U healthlog"]
|
||||
interval: 5s
|
||||
@@ -639,5 +688,199 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
|
||||
# on the first start after the update. A major version cannot read the data
|
||||
# directory of an older one, so changing the image line alone would leave
|
||||
# the database refusing to start.
|
||||
#
|
||||
# On every start it decides in a second and exits:
|
||||
# - no `pgdata` data (a new install): nothing to do;
|
||||
# - the database service runs Postgres 16 (after going back): nothing to
|
||||
# do, the live 16 volume is left alone;
|
||||
# - 16 was used again after an earlier move: refuses, so the app does not
|
||||
# start on the move-day copy;
|
||||
# - Postgres 18 already holds the database: nothing to do;
|
||||
# - otherwise it starts Postgres 16 inside this container on the old
|
||||
# volume, streams a dump into the new database in one transaction,
|
||||
# compares the row count of every table, gathers planner statistics and
|
||||
# marks the old volume as moved. `app` starts only after it succeeded.
|
||||
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
|
||||
# the next start empties the new database and runs it again from scratch.
|
||||
#
|
||||
# The old volume is read, never changed, apart from that marker file, so
|
||||
# going back is the previous image and volume lines. If anything fails, the
|
||||
# new database stays empty, `app` does not start, and the log says why.
|
||||
# It costs one small image pull; a later release removes the service.
|
||||
db-upgrade:
|
||||
image: postgres:16.15-alpine
|
||||
container_name: healthlog-db-upgrade
|
||||
# Runs once per `compose up` and exits. "no" also keeps hosts such as
|
||||
# Coolify from reporting the exited container as a failed service.
|
||||
restart: "no"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Read-only, only to check there is room for the copy before starting.
|
||||
- postgres-data:/var/lib/postgresql/next:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
# `$$` is how compose spells a literal `$`.
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -eu
|
||||
set -o pipefail
|
||||
log() { echo "db-upgrade: $$*"; }
|
||||
OLD=/var/lib/postgresql/data
|
||||
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
|
||||
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
|
||||
export PGPASSWORD="$$POSTGRES_PASSWORD"
|
||||
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
|
||||
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
|
||||
empty_new() {
|
||||
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
|
||||
new "DROP SCHEMA $$S CASCADE"
|
||||
done
|
||||
new "CREATE SCHEMA public"
|
||||
}
|
||||
if [ ! -s "$$OLD/PG_VERSION" ]; then
|
||||
log "no Postgres 16 database to move, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
# The database service must be the new major. After going back to 16 the
|
||||
# service runs on this very volume: touching it then would wipe the live data.
|
||||
i=0
|
||||
until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do
|
||||
i=$$((i + 1))
|
||||
[ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
if [ "$$NEWVER" -lt 180000 ]; then
|
||||
log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone"
|
||||
exit 0
|
||||
fi
|
||||
checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; }
|
||||
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
|
||||
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
|
||||
if [ -e "$$MARK" ]; then
|
||||
MOVED_ON=$$(sed -n 1p "$$MARK")
|
||||
MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK")
|
||||
if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then
|
||||
log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16."
|
||||
log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database,"
|
||||
log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$$TABLES" != 0 ]; then
|
||||
log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
log "the Postgres 16 database was moved on $$MOVED_ON, but the Postgres 18 database is empty now."
|
||||
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$$MOVING" ]; then
|
||||
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
|
||||
empty_new
|
||||
elif [ "$$TABLES" != 0 ]; then
|
||||
log "the Postgres 18 database already holds data, leaving both as they are"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=$$(cat "$$OLD/PG_VERSION")
|
||||
if [ "$$VERSION" != 16 ]; then
|
||||
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
|
||||
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
|
||||
exit 1
|
||||
fi
|
||||
NEED=$$(du -sk "$$OLD" | cut -f1)
|
||||
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
|
||||
# Room for the copy, and for the dump the app takes before its first migration.
|
||||
if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then
|
||||
log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, the disk has $$((FREE / 1024)) MiB."
|
||||
log "Nothing was changed. Free some space and start the stack again."
|
||||
exit 1
|
||||
fi
|
||||
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
|
||||
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
|
||||
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
|
||||
PID=$$!
|
||||
i=0
|
||||
until pg_isready -h /tmp -U healthlog -q; do
|
||||
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
|
||||
i=$$((i + 1))
|
||||
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
pg_dump -h /tmp -U healthlog -Fc healthlog |
|
||||
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
|
||||
COUNTED=0
|
||||
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
|
||||
A=$$(old "SELECT count(*) FROM $$T")
|
||||
B=$$(new "SELECT count(*) FROM $$T")
|
||||
if [ "$$A" != "$$B" ]; then
|
||||
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
|
||||
log "Please report this with the lines above."
|
||||
empty_new
|
||||
exit 1
|
||||
fi
|
||||
COUNTED=$$((COUNTED + 1))
|
||||
done
|
||||
log "all $$COUNTED tables arrived with the same row counts"
|
||||
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
|
||||
kill -INT "$$PID"
|
||||
wait "$$PID" || true
|
||||
# The marker keeps the old database's last checkpoint: a later start can tell
|
||||
# whether 16 was used again after the move.
|
||||
{ date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING"
|
||||
mv "$$MOVING" "$$MARK"
|
||||
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
|
||||
|
||||
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
|
||||
# Never started by `docker compose up`; run it by name:
|
||||
#
|
||||
# docker compose run --rm db-restore list
|
||||
# docker compose run --rm db-restore latest
|
||||
# docker compose run --rm db-restore healthlog-<time>-daily.dump
|
||||
# docker compose run --rm db-restore latest --from-bucket
|
||||
#
|
||||
# It checks the dump against its manifest and the keys in .env against the
|
||||
# keys the dump was written with, refuses a database that already holds
|
||||
# tables, restores in one transaction and compares every table's row count
|
||||
# with the manifest. Start the app only after it says "Restored".
|
||||
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
|
||||
# (`database/`, the BACKUP_S3_* variables) and opens them with
|
||||
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
|
||||
# gone. Files you downloaded yourself restore from a directory you mount:
|
||||
# `docker compose run --rm -v ./restore:/restore:ro db-restore
|
||||
# /restore/healthlog-<time>-daily.dump.enc`.
|
||||
db-restore:
|
||||
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
profiles: ["restore"]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
environment:
|
||||
<<: *healthlog_env
|
||||
volumes:
|
||||
- backups:/var/lib/healthlog/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["node", "scripts/db-restore.mjs"]
|
||||
command: ["list"]
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
pgdata:
|
||||
backups:
|
||||
@@ -88,6 +88,17 @@ echo "HealthLog: Database is reachable."
|
||||
# Say how many migrations this image is about to apply, with a reminder to
|
||||
# back up first. Reporting only: it never stops the boot.
|
||||
node scripts/pending-migrations.mjs || true
|
||||
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
|
||||
# the state before them can be restored (docs/ops/disaster-recovery.md). No
|
||||
# directory that survives the container (none writable, or the image's own
|
||||
# without the backups volume): a warning, and the boot goes on. A kept
|
||||
# directory and a failed dump: the boot stops here, before anything is
|
||||
# migrated.
|
||||
if ! node scripts/db-dump.mjs pre-migration; then
|
||||
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
|
||||
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
|
||||
exit 1
|
||||
fi
|
||||
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
|
||||
# migration that timed out on a lock and is marked safe to re-run; any other
|
||||
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
|
||||
|
||||
+13
-7
@@ -17,9 +17,13 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
encryption-key rotation, password reset and account recovery, env
|
||||
checks, TLS pinning, data repair. These are what an operator reaches
|
||||
for during an incident.
|
||||
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
|
||||
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
|
||||
[`getting-started.md`](./self-hosting/getting-started.md) (install and
|
||||
updates, the page the boot log points at), the TrueNAS, Unraid and
|
||||
Portainer installs with their catalog templates, Tailscale, the reverse
|
||||
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
|
||||
notification channels, the environment module, document sources and
|
||||
reproductive-health data. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
@@ -44,9 +48,11 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
- [`migration/`](./migration/) — migration notes. Currently only
|
||||
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
|
||||
<https://docs.healthlog.dev>.
|
||||
- [`integrations/`](./integrations/) — per-integration reference
|
||||
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
|
||||
data import).
|
||||
- [`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
|
||||
imports; AI providers; and Home Assistant with its blueprint. Oura and
|
||||
Polar are covered on the docs site only.
|
||||
- [`adr/`](./adr/) — architecture decision records.
|
||||
- [`security/`](./security/) — threat-model notes.
|
||||
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
|
||||
@@ -70,4 +76,4 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
|
||||
If you're a self-hoster looking to spin up an instance, start at
|
||||
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
|
||||
README](../README.md#quick-start).
|
||||
README](../README.md#quick-start-docker-compose).
|
||||
+11
-4
@@ -66,7 +66,11 @@ before treating a warning as a regression.
|
||||
`health:write`), audience-bound to `/mcp` and refused on REST writes;
|
||||
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
|
||||
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
|
||||
holder's own record only;
|
||||
holder's own record only. The readings it writes carry `source: EXTERNAL`
|
||||
and are linked to the token; whether the HealthLog app writes them into
|
||||
Apple Health is the token's `mirrorToAppleHealth` setting (off by
|
||||
default, changed with `PATCH /api/tokens/{id}`), resolved onto every
|
||||
measurement row as `appleHealthMirror`;
|
||||
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
|
||||
`POST /api/workouts/batch` only, on the holder's own record only; the
|
||||
workouts it writes carry `source: EXTERNAL`;
|
||||
@@ -78,9 +82,12 @@ before treating a warning as a regression.
|
||||
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
|
||||
every token above, whichever surface issued it.
|
||||
|
||||
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
|
||||
password and passkey login flows. Used by the web app and any iOS
|
||||
client running inside a web view.
|
||||
- `sessionCookie` — HttpOnly session cookie set by the password and
|
||||
passkey login flows. Used by the web app and any iOS client running
|
||||
inside a web view. It is named `__Host-healthlog_session` wherever it is
|
||||
`Secure` (every HTTPS install), so no other host under the same domain
|
||||
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
|
||||
under the old name is still read and is re-issued under the new one.
|
||||
- **`components.schemas`** — DTOs for the documented routes plus the
|
||||
underlying Prisma models (e.g. `Measurement`, `Medication`,
|
||||
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
|
||||
|
||||
+1532
-280
File diff suppressed because it is too large.
Load diff
@@ -630,7 +630,9 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
|
||||
|
||||
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
|
||||
|
||||
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
|
||||
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
|
||||
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. The gpt-6 slugs were not probed against a live backend when they joined the chain
|
||||
- `gpt-5.5` — the default from 2026-06 until 2026-10-24
|
||||
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
|
||||
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
|
||||
|
||||
@@ -642,19 +644,23 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c
|
||||
|
||||
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
|
||||
|
||||
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
|
||||
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
|
||||
|
||||
```ts
|
||||
const DEFAULT_SLUG_FALLBACK_CHAIN = [
|
||||
"gpt-5.5", // current default across paid tiers as of 2026-06-02
|
||||
"gpt-5.4", // documented fallback
|
||||
"gpt-5.4-mini", // documented lighter-weight fallback
|
||||
"gpt-5.3-codex", // late rung — still accepted intermittently
|
||||
"gpt-5.2", // legacy floor
|
||||
"gpt-5.6-terra", // verified against the backend 2026-10-24
|
||||
"gpt-6-sol", // terra's upgrade in the upstream catalog
|
||||
"gpt-6.1-sol", // the catalog default, on every plan
|
||||
"gpt-6-luna", // lighter gpt-6 sibling
|
||||
"gpt-5.6-luna", // lighter sibling on terra's line
|
||||
"gpt-5.6-sol", // heavier sibling on terra's line
|
||||
"gpt-5.5", // previous default, kept as the last rung while plans differ
|
||||
];
|
||||
```
|
||||
|
||||
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
|
||||
Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that.
|
||||
|
||||
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
|
||||
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Never point the seed or the benchmark at a database that holds real data. They w
|
||||
```sh
|
||||
docker run -d --name hl-bench-db --shm-size=256m \
|
||||
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
|
||||
-p 55432:5432 postgres:16.15-alpine \
|
||||
-p 55432:5432 postgres:18.6-alpine \
|
||||
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
|
||||
```
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ is about choosing and writing tests.
|
||||
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
|
||||
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
|
||||
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
|
||||
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
|
||||
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
|
||||
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
|
||||
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
|
||||
@@ -27,6 +28,28 @@ ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
|
||||
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
|
||||
(`safeFetch`), APNs, SMTP, the model.
|
||||
|
||||
## Critical journeys
|
||||
|
||||
A few e2e journeys carry `@critical` in their test titles: signing in with a
|
||||
password and a passkey and signing out, a reading reaching the dashboard, a
|
||||
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
|
||||
German and Brazilian Portuguese, a setting that persists, a backup coming
|
||||
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
|
||||
|
||||
They run against the real server and a seeded Postgres. A spec file that
|
||||
carries the tag may not intercept anything under `/api`, directly or through
|
||||
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
|
||||
refuses it and keeps a floor on the number of tagged tests. Stubbing an
|
||||
unreachable third-party host that is not the journey's subject is allowed,
|
||||
with the reason next to the call.
|
||||
|
||||
A journey that writes gets an account nobody else writes. The accounts in
|
||||
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
|
||||
and per Chromium project, so a journey runs at both widths without the two
|
||||
projects meeting. Reset what a previous run left with `resetCriticalAccount`
|
||||
in a `beforeEach`, and read results through the app's own routes rather than
|
||||
through the database.
|
||||
|
||||
## The route test kit
|
||||
|
||||
`tests/kit/` holds two modules for tests under `tests/integration/`.
|
||||
@@ -114,6 +137,43 @@ into a fresh account and reads it back there. A new setting column without a
|
||||
table entry fails the file. A column with no write route says why in its
|
||||
entry.
|
||||
|
||||
## Browser tests
|
||||
|
||||
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
|
||||
in headless Chromium through Vitest's Playwright provider, as part of
|
||||
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
|
||||
with `pnpm exec vitest run --project browser <path>`. The Node projects never
|
||||
collect these files and this project collects nothing else. CI installs the
|
||||
browser it needs (`playwright install --only-shell chromium`); locally, run
|
||||
that once.
|
||||
|
||||
Reach for it when the claim is about behaviour a markup test cannot see:
|
||||
focus, keys, an open menu, a pending state, the body a form posts, the URL a
|
||||
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
|
||||
state renders, and bottom-sheet gestures stay in `e2e/`.
|
||||
|
||||
The helper is `tests/browser/`:
|
||||
|
||||
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
|
||||
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
|
||||
catalog. The signed-in account is seeded where `useAuth` reads it, with
|
||||
`authUser()` defaults under `user`. The app stylesheet and the default
|
||||
theme class are loaded, so layout, visibility and colour tokens resolve.
|
||||
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
|
||||
`.on(method, path, { status, data, error, meta })`, hold one with
|
||||
`.deferred(...)` to assert a pending state, and read the parsed bodies from
|
||||
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
|
||||
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
|
||||
- `next/navigation` is aliased to a shim over the page's real `location` and
|
||||
`history`; `url.ts` sets the starting query, records pushes and replaces,
|
||||
and simulates Back or an edited address.
|
||||
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
|
||||
|
||||
Every test gets the real `fetch`, clock, address and an empty `localStorage`
|
||||
back afterwards. Locate by role and accessible name, the way a person finds a
|
||||
control; a `Label` adds a colon to its name, so match a labelled combobox with
|
||||
`/^Type/` rather than `"Type"`.
|
||||
|
||||
## The integration database
|
||||
|
||||
Global setup boots one Postgres container and migrates one template database.
|
||||
@@ -152,7 +212,8 @@ node scripts/test-durations.mjs unit unit.json
|
||||
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
|
||||
regenerated from CI timings, which is the better source.
|
||||
|
||||
The unit suite is three projects, decided per file from its source
|
||||
The unit suite is three Node projects and the `browser` project above. The
|
||||
Node ones are decided per file from its source
|
||||
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
|
||||
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
|
||||
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
|
||||
|
||||
@@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
|
||||
|
||||
## 2. Spacing scale
|
||||
|
||||
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
|
||||
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
|
||||
|
||||
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
|
||||
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
|
||||
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
|
||||
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
|
||||
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
|
||||
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
|
||||
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
|
||||
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
|
||||
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
|
||||
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
|
||||
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
|
||||
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
|
||||
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
|
||||
@@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr
|
||||
|
||||
## 9. One scroll floor; sub-shells never re-declare bottom-space
|
||||
|
||||
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
|
||||
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
|
||||
|
||||
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
|
||||
|
||||
@@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it
|
||||
|
||||
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
|
||||
|
||||
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
|
||||
|
||||
## 11. Inline action row; the PageHeader pattern
|
||||
|
||||
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<text x="600" y="360" text-anchor="middle" fill="#6272a4" font-size="11">pre-aggregated buckets</text>
|
||||
|
||||
<rect x="510" y="392" width="180" height="56" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
|
||||
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 16</text>
|
||||
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 18</text>
|
||||
<text x="600" y="435" text-anchor="middle" fill="#bd93f9" font-size="11">encrypted at rest</text>
|
||||
</g>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
|
||||
<title id="title">HealthLog self-hosting topology</title>
|
||||
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
|
||||
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 18 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
|
||||
|
||||
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<!-- Postgres (green) -->
|
||||
<g>
|
||||
<rect x="760" y="250" width="200" height="80" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
|
||||
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 16</text>
|
||||
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 18</text>
|
||||
<text x="860" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">26 Prisma models</text>
|
||||
<text x="860" y="312" text-anchor="middle" fill="#6272a4" font-size="11">+ pg-boss queues</text>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -10,7 +10,7 @@ copy.
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
|
||||
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 18, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
|
||||
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ Concrete consequences:
|
||||
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
|
||||
separate from the SDNN one.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -57,23 +57,50 @@ arrays:
|
||||
}
|
||||
```
|
||||
|
||||
Each array holds at most **10 000** entries. The endpoint is rate-limited
|
||||
to **5 imports per hour per account**. Existing rows (matched on the
|
||||
type + timestamp uniqueness, or on `externalId` for mood entries) are
|
||||
skipped and counted under `skipped` in the response.
|
||||
Each array holds at most **10 000** entries and the file at most 16 MB.
|
||||
The endpoint is rate-limited to **5 imports per hour per account**.
|
||||
Existing rows (matched on the type + timestamp uniqueness, or on
|
||||
`externalId` for mood entries) are skipped and counted under `skipped` in
|
||||
the response.
|
||||
|
||||
The app's own files go straight in: the JSON export (**Export → JSON**,
|
||||
read inside its `data` envelope) and the portable full backup. Every field
|
||||
either file writes for a reading or a mood entry comes back, apart from
|
||||
the row ids, the source (every imported row is stored as `IMPORT`) and the
|
||||
sync bookkeeping of the account the file came from. An empty field, `null`
|
||||
in the backup and `""` in the export, reads as absent, a backup's deletion
|
||||
records are skipped and counted, and a reading in a display unit (lb, °F,
|
||||
mmol/L) is converted to the stored one.
|
||||
|
||||
A mood entry's tags and rated factors are linked by key. A full backup
|
||||
also carries the account's own tags, rated factors and tag groups, and the
|
||||
import brings them along: a tag the importing account already has is used
|
||||
as it is, and one it lacks is created (within the account's limit of 50
|
||||
custom tags and 12 groups). The account's own tag is never overwritten: when
|
||||
the file names it differently, the account's name stays and the response
|
||||
lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group).
|
||||
Moving a file between two accounts of one server works too; the tags come
|
||||
over under keys of their own. A key the account cannot resolve at all is
|
||||
dropped from that entry and named in `droppedTagKeys` or
|
||||
`droppedFactorKeys`, and the rest of the entry lands.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
|
||||
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
|
||||
| `valueMax` | no | number | Upper bound of a range reading. |
|
||||
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
|
||||
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
|
||||
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### Glucose context
|
||||
|
||||
@@ -98,14 +125,20 @@ moment you know it.
|
||||
|
||||
### Mood entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `note` | no | string | The entry's note, up to 500 chars. |
|
||||
| `a1`–`a5` | no | integer | The five level-A values, `0`–`10`. Without `a1`, pleasantness is derived from `mood`. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
|
||||
| `tz` | no | string | The IANA zone `date` is anchored to. |
|
||||
| `factors` | no | array | Rated factors, `[{ "key": "…", "rating": 1–5 }]`; replace the entry's rated links. |
|
||||
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
|
||||
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### `MeasurementType` values and canonical units
|
||||
|
||||
|
||||
@@ -1,82 +1,7 @@
|
||||
# Dawarich integration
|
||||
|
||||
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
|
||||
instance: how far you moved, how much of it by car or by train, how many places
|
||||
you visited, how long you were away from home, and whether a day was spent in
|
||||
another country. It is part of the Environment module and runs only while that
|
||||
module is on.
|
||||
|
||||
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
|
||||
the day summaries and keeps one row of numbers per day. The place list is read
|
||||
live when you pick your home and is not saved.
|
||||
|
||||
## What is read
|
||||
|
||||
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
|
||||
yesterday. Connecting fetches the last 30 days. Each request carries your API
|
||||
key as `Authorization: Bearer <key>`.
|
||||
|
||||
| Dawarich endpoint | What HealthLog keeps |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
|
||||
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
|
||||
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
|
||||
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
|
||||
| `GET /api/v1/users/me` | nothing; used to check the key |
|
||||
|
||||
Time away from home is the day's length minus the time a visit at your home
|
||||
place covers. Without a home place, or on a day without a single visit, it is
|
||||
left empty rather than shown as zero. Dawarich leaves out a mode that moved
|
||||
less than half a kilometre; HealthLog then reads that mode as zero for the day.
|
||||
|
||||
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
|
||||
has it. Without it there are no travel days, and everything else still works.
|
||||
Dawarich keys residency by UTC date, so on a day that crosses a border late in
|
||||
the evening the country can be the next morning's.
|
||||
|
||||
## Connect
|
||||
|
||||
1. In Dawarich, open your account settings and copy the API key.
|
||||
2. In HealthLog, turn on the Environment module, then open
|
||||
**Settings → Integrations → Dawarich**.
|
||||
3. Enter the address of the instance and the key, and connect.
|
||||
4. Pick your home place from the list, and set your home country if you like.
|
||||
Without one, the country you spent most days in over the last year counts.
|
||||
|
||||
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
|
||||
timeline by its own zone, HealthLog by yours; if they differ, a day can come
|
||||
back without its distances.
|
||||
|
||||
## A Dawarich instance on your own network
|
||||
|
||||
A public instance works without any server setting. An instance on a private
|
||||
network (a LAN address, a `.lan` name, a container on the same host) is refused
|
||||
unless the operator lists its exact origin:
|
||||
|
||||
```env
|
||||
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
|
||||
```
|
||||
|
||||
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
|
||||
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
|
||||
separated, no path, no wildcard and no range. Listing a host does not open its
|
||||
subdomains or another port. The unspecified address, link-local and the
|
||||
cloud-metadata range can never be listed. Restart HealthLog after changing it.
|
||||
The variable is on the `environment:` whitelist in `docker-compose.yml`.
|
||||
|
||||
## Where the values show up
|
||||
|
||||
- The day view has an **On the move** section with the day's figures.
|
||||
- Time away from home and the distance travelled join the correlation scan as
|
||||
two more factors.
|
||||
- Runs of at least two travel days that no trip covers yet are offered as
|
||||
trips under **Settings → Environment → Location periods**. Using one fills
|
||||
in the dates; you pick the place.
|
||||
- The Coach and the MCP `get_day` tool see the figures and whether it was a
|
||||
travel day, never the country.
|
||||
|
||||
## Disconnect
|
||||
|
||||
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
|
||||
and delete days** removes them as well. The days are part of the backup; the
|
||||
address and key are not, so a restored account connects again.
|
||||
The Dawarich guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
|
||||
and what is never stored, connecting an instance, allowing one on your own
|
||||
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
|
||||
disconnecting.
|
||||
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
|
||||
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
|
||||
the source priority ladder in **Settings → Sources** decides which one wins
|
||||
the source priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric. FITBIT is ranked in every default metric ladder; override
|
||||
per metric on that page.
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
|
||||
priority ladder in **Settings → Sources** decides which one wins
|
||||
priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric.
|
||||
|
||||
## Disconnect
|
||||
|
||||
@@ -1,74 +1,6 @@
|
||||
# Strava integration
|
||||
|
||||
HealthLog reads your Strava activities and folds them into the same workout
|
||||
history as every other source. Strava is a **workout source only** — its API is
|
||||
activity-centric and exposes no sleep, recovery, body-composition or glucose
|
||||
data, so connecting it adds runs, rides, swims and the rest to your workout
|
||||
surface and nothing else.
|
||||
|
||||
The OAuth app is brought **per user**: each self-hoster registers their own
|
||||
Strava API application and pastes the client id/secret into Settings. Strava
|
||||
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
|
||||
until you request a capacity increase — perfectly fine for a personal
|
||||
self-host, and the reason a single shared app cannot serve many operators.
|
||||
|
||||
## What you get — and what you don't
|
||||
|
||||
**Covered** (one `Workout` row per activity): sport type, start and end time,
|
||||
moving duration, distance, elevation gain, average and maximum heart rate (when
|
||||
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
|
||||
flags and the activity name are kept on the row's metadata for provenance but
|
||||
are **not** surfaced as training analytics — HealthLog treats Strava as another
|
||||
workout source, not a training-load platform.
|
||||
|
||||
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
|
||||
rate, weight, …). Those come from your wearable's own integration or from Apple
|
||||
Health / Google Health Connect.
|
||||
|
||||
## Duplicate activities are collapsed automatically
|
||||
|
||||
If you record a run on an Apple Watch **and** it also lands in Strava, the same
|
||||
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
|
||||
canonical row per logical workout using your **source-priority ladder**: by
|
||||
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
|
||||
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
|
||||
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
|
||||
survives untouched. You can promote Strava above the wearables in
|
||||
**Settings → Sources** if you prefer the Strava copy.
|
||||
|
||||
## 1. Register a Strava API application
|
||||
|
||||
1. Sign in at <https://www.strava.com/settings/api>.
|
||||
2. Create an application. For **Authorization Callback Domain** enter the host
|
||||
of your HealthLog instance (the domain only, no scheme or path).
|
||||
3. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 2. Paste the credentials into HealthLog
|
||||
|
||||
1. Open **Settings → Integrations** and find the **Strava** card.
|
||||
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
|
||||
save. (An operator can instead configure a shared app via the
|
||||
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
|
||||
credentials always take precedence.)
|
||||
3. Click **Connect Strava** and approve the access request. HealthLog requests
|
||||
the `activity:read_all` scope so it can read every activity, including those
|
||||
you marked "only me".
|
||||
|
||||
## 3. First sync
|
||||
|
||||
The first history import runs in the background shortly after you connect;
|
||||
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
|
||||
on every refresh, and HealthLog persists the rotated token each time, so the
|
||||
connection stays healthy without you re-authorising.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Disconnecting revokes the grant at Strava and clears the stored tokens.
|
||||
Activities already imported stay in your history; reconnecting resumes the sync.
|
||||
|
||||
## Related
|
||||
|
||||
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
|
||||
own page, or ingest via [Apple Health](./apple-health.md) /
|
||||
[Google Health Connect](./google-health.md).
|
||||
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
|
||||
The Strava guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
|
||||
adds (workouts only), how duplicate activities from a wearable are collapsed,
|
||||
registering the Strava API application, the first sync and disconnecting.
|
||||
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
`/settings/integrations#withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -181,7 +181,7 @@ import:
|
||||
when Apple Health is also linked, so the Apple Health stream wins
|
||||
for the cumulative axis. The Withings rows stay in the DB.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -199,7 +199,7 @@ Override per-user via Settings → Sources (`/settings/sources`).
|
||||
### The webhook stops firing
|
||||
|
||||
Webhook subscriptions can lapse after a token revoke or a portal-
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
|
||||
and clicking **Disconnect** then **Connect Withings** again — the
|
||||
callback exchange re-runs `Notify.subscribe()` with a fresh callback
|
||||
URL.
|
||||
|
||||
+132
-470
@@ -1,507 +1,169 @@
|
||||
# Off-host backup & restore
|
||||
# Account copies and restore
|
||||
|
||||
HealthLog ships with an optional daily off-host backup that ships every
|
||||
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
|
||||
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
|
||||
AWS S3, Backblaze B2, MinIO, etc.
|
||||
HealthLog keeps copies of each account so that one account can be rolled back
|
||||
on this server. These copies are per account. They cannot rebuild a lost
|
||||
server: a restore writes into an account that already exists here under the
|
||||
same id, and refuses a copy whose account is gone. To recover a server, you
|
||||
need a database dump and the encryption keys. That procedure is in
|
||||
[disaster-recovery.md](disaster-recovery.md).
|
||||
|
||||
The backup runs at **02:30 Europe/Berlin** every day from the worker
|
||||
container (queue `data-backup-offhost`). Object key layout:
|
||||
## What exists
|
||||
|
||||
```
|
||||
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
|
||||
```
|
||||
| Copy | Where | When | Kept |
|
||||
| ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week |
|
||||
| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it |
|
||||
| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted |
|
||||
| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it |
|
||||
| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it |
|
||||
|
||||
## Wire format (binary)
|
||||
The weekly, pre-wipe and off-host copies carry the whole record, document
|
||||
files and ECG traces included, with the database's ciphertext inside them as
|
||||
stored. An uploaded copy holds whatever its file held. The personal
|
||||
export is portable: readable values, and documents as metadata only. It is
|
||||
meant for the person to keep and read. A restore refuses it when the file
|
||||
lists any document, because it carries no document content to put back.
|
||||
|
||||
```
|
||||
magic = "HLBK" (4 bytes, ASCII)
|
||||
version = 0x04 (1 byte)
|
||||
keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
|
||||
iv = 12 random bytes (AES-GCM nonce)
|
||||
ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
|
||||
associated data = "healthlog/offhost-backup/v4|<object key>")
|
||||
authTag = 16 bytes (AES-GCM tag, trailing)
|
||||
```
|
||||
Two kinds of data are not in any account copy: workout GPS routes and sample
|
||||
series. Credentials, devices, share links and account grants are left out on
|
||||
purpose (see "What a restore does not touch").
|
||||
|
||||
Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
|
||||
key an object needs, so a reader holding a retired key as well finds the right
|
||||
one. The associated data is the object's own key in the bucket,
|
||||
`YYYY-MM-DD/user-<id>.json.enc`: an object copied to another account's name or
|
||||
another date does not decrypt, where before it opened as if it belonged there.
|
||||
The restore script passes the key it downloaded, so nothing changes for an
|
||||
operator.
|
||||
The JSON import in Settings → Export & import is not a restore. It adds
|
||||
measurements and mood entries, up to 16 MB and 10,000 entries per list.
|
||||
|
||||
Four versions exist in the wild and all four restore. `0x01` encrypted the
|
||||
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
|
||||
ciphertext, which is what made them impossible to write a piece at a time —
|
||||
GCM only produces the tag once the last block is in, so a leading tag means the
|
||||
whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still
|
||||
covers every ciphertext byte, and the reader still verifies it before returning
|
||||
a single byte of plaintext. Objects already in your bucket stay readable, and
|
||||
the restore script needs no flag to tell them apart.
|
||||
## The deletion grace window
|
||||
|
||||
## Required env vars
|
||||
`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an
|
||||
erasure waits:
|
||||
|
||||
| Var | Required | Notes |
|
||||
| --------------------------------- | -------- | ----------------------------------------------------------------------------------- |
|
||||
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
|
||||
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
|
||||
| `BACKUP_S3_BUCKET` | yes | |
|
||||
| `BACKUP_S3_ACCESS_KEY` | yes | |
|
||||
| `BACKUP_S3_SECRET_KEY` | yes | |
|
||||
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
|
||||
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
|
||||
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") |
|
||||
- **Deleting an account**, or a guardian deleting a **managed profile**, locks
|
||||
the account at once. Every session, refresh token, API token, connector and
|
||||
share link is revoked, sign-in is refused with the date the account is
|
||||
erased, and nothing runs for the account any more: no reminders, syncs,
|
||||
copies, scores or AI work. The record, its stored copies and its off-host
|
||||
objects stay as they are. **Admin → Users** shows the account with its
|
||||
erasure date and an **Undo deletion** button. When the window ends, the
|
||||
hourly `account-deletion-purge` job erases the account and asks the bucket to
|
||||
delete its off-host objects.
|
||||
- **Delete all data** first stores the whole record as a copy of its own
|
||||
(shown as "Before all data was deleted" in Admin → Backups), then empties the
|
||||
record. An admin can restore that copy until the window ends. The off-host
|
||||
objects from before the wipe are kept until then too.
|
||||
|
||||
## Bucket permissions
|
||||
Database dumps are not purged per account: a deleted account stays in them
|
||||
until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's
|
||||
lifecycle rule for `database/`). The deletion dialogs say so.
|
||||
|
||||
The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and
|
||||
`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token
|
||||
covers all of them.
|
||||
`0` erases immediately, as releases before 1.46 did. The Data & Privacy page
|
||||
and the deletion dialogs state the configured number.
|
||||
|
||||
`DeleteObject` is needed because the app deletes objects in two cases, and
|
||||
only those:
|
||||
## Restoring an account
|
||||
|
||||
- **An account is deleted, or its data is wiped.** Deleting an account,
|
||||
deleting a managed profile and "Delete all data" each write a deletion
|
||||
request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every
|
||||
`YYYY-MM-DD/user-<id>.json.enc` of that account and removes the request once
|
||||
nothing of the account is left. It runs right after the deletion and again
|
||||
every night at 03:40. A copy the nightly upload was writing while the account
|
||||
was deleted is removed by the upload itself.
|
||||
- **The connection test** deletes its own `_healthcheck/<ts>.bin`.
|
||||
In **Admin → Backups**, every stored copy has **Restore**. The dialog first
|
||||
shows what the copy holds, then asks you to type `RESTORE`. The restore runs
|
||||
in the background and replaces the account's data tables: every row the copy
|
||||
holds comes back under its original id, and every row the account gained
|
||||
after the copy was taken is deleted. If the current state is worth keeping,
|
||||
press **Backup now** first.
|
||||
|
||||
The admin wipe of every account deletes nothing in the bucket. It is you
|
||||
clearing your own host, the bucket is yours, and its copies are the way back
|
||||
from a wipe pressed by mistake. If you are handing the host on, empty the
|
||||
bucket yourself.
|
||||
_Also restore instance settings_ writes the registration, MFA, locale, module,
|
||||
notification and AI settings from the copy back for the whole server. Leave it
|
||||
clear unless those settings themselves need to go back.
|
||||
|
||||
Earlier versions of this page said the worker never deletes an object. That
|
||||
was the design until v1.39.3, and it meant a deleted account's copies stayed
|
||||
in the bucket for as long as the lifecycle rule kept them, or forever without
|
||||
one. If the bucket refuses the delete (a credential without `DeleteObject`),
|
||||
the request stays, the job retries it every night, and **Admin → Backups →
|
||||
Off-host** shows how many deletions are outstanding and the bucket's answer.
|
||||
|
||||
`AbortMultipartUpload` only reaches an upload the same worker started and is
|
||||
what clears the parts of a run that failed halfway. Without it, a failed
|
||||
upload leaves parts that are billed and do not show in a bucket listing.
|
||||
|
||||
## Bucket lifecycle (required for retention)
|
||||
|
||||
The app does not expire objects by age, and nothing in the app reads
|
||||
`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set
|
||||
to match the number you wrote there. Without one, every nightly copy of every
|
||||
account is kept forever. **Admin → Backups → Off-host** reads the bucket's
|
||||
lifecycle configuration and says so when there is none, or when the credential
|
||||
may not read it (grant `GetLifecycleConfiguration` to let it check):
|
||||
|
||||
```
|
||||
Filter: "" (all objects)
|
||||
Action: Expire after 30 days
|
||||
```
|
||||
|
||||
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
|
||||
|
||||
## Smoke test
|
||||
|
||||
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
|
||||
performs a 1-byte PUT + GET round-trip and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
The credentials are never returned.
|
||||
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run the restore script with the same backup credentials and
|
||||
encryption key the backup was written under — a freshly generated
|
||||
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
|
||||
To restore from an **off-host object**, fetch and decrypt it from a source
|
||||
checkout with the backup variables of the server that wrote it:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
|
||||
pnpm dlx tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \
|
||||
BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_ENCRYPTION_KEY=<the key the object was written under> \
|
||||
pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restored.json
|
||||
```
|
||||
|
||||
Run this command from a source checkout with the production backup variables
|
||||
exported. The script imports the full application dependency graph, which the
|
||||
minimal production image does not expose as an operator scripting environment.
|
||||
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
|
||||
compression) and restore it from its row. The account `<id>` must exist on this
|
||||
server.
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
Every restore, preview and upload checks first that this server holds every
|
||||
application key the copy's content was written under, and refuses with the key
|
||||
id named if not. Nothing is changed by a refused restore.
|
||||
|
||||
Restoring is not streamed and does not need to be. It holds the whole document,
|
||||
because the next thing anyone does with a backup is parse it as one JSON
|
||||
object, and handing back plaintext the auth tag has not yet covered would trade
|
||||
the authentication for memory. It runs on your machine rather than in the
|
||||
container, so give it room: a record of several hundred thousand measurements
|
||||
decompresses to a few hundred megabytes, and
|
||||
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
|
||||
a 445 000-measurement account.
|
||||
### What a restore does not touch
|
||||
|
||||
### Restoring from the backups console
|
||||
|
||||
An admin can restore a stored snapshot from the browser instead of the shell.
|
||||
`/admin/backups` lists one row per account and per backup type: **Backup now**
|
||||
enqueues the `data-backup` pass for every account on the instance, **Download**
|
||||
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
|
||||
that first shows what the file carries and then requires the word `RESTORE` to
|
||||
be typed before it will run. The restore targets the account the snapshot was
|
||||
taken for, never the admin running it, and it refuses a file whose declared
|
||||
owner does not match the stored row. Instance-wide settings ride along only
|
||||
when you tick _Also restore instance settings_ in the dialog; leave it clear to
|
||||
restore the account alone.
|
||||
|
||||
A stored copy this instance can no longer open — the key that wrote it dropped
|
||||
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
|
||||
written — is refused by all three buttons with `422` and
|
||||
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
|
||||
|
||||
### What a backup deliberately does not carry
|
||||
|
||||
Every credential-shaped row is left out, and this is not an oversight to fix:
|
||||
API tokens, trusted devices, step-up elevations, known devices, clinician share
|
||||
links, and the account grants behind shared record access. Restoring data is
|
||||
rolling a record back to a known state. Restoring an authorization is different
|
||||
in kind, because a grant the owner revoked on Tuesday would come back alive out
|
||||
of Monday's file with nobody deciding it and neither person told.
|
||||
links and account grants are not in any copy. A restore on this server leaves
|
||||
them as they are, so shared access carries on. The reasoning per model is in
|
||||
`src/lib/export/backup-plan.ts`.
|
||||
|
||||
What this means in practice depends on where you restore to.
|
||||
## Off-host copies
|
||||
|
||||
**Onto the same instance.** Nothing changes. The restore replaces the account's
|
||||
data tables and does not touch grants, tokens or devices, so shared access
|
||||
carries on exactly as it was. Somebody who had read access before the restore
|
||||
still has it afterwards, now looking at the restored data.
|
||||
| Variable | Required | Notes |
|
||||
| --------------------------------- | -------- | --------------------------------------------------------------------- |
|
||||
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` |
|
||||
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
|
||||
| `BACKUP_S3_BUCKET` | yes | |
|
||||
| `BACKUP_S3_ACCESS_KEY` | yes | |
|
||||
| `BACKUP_S3_SECRET_KEY` | yes | |
|
||||
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
|
||||
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number to put in the bucket's lifecycle rule |
|
||||
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only |
|
||||
|
||||
**Onto a fresh instance.** None of it comes with the file. Nobody has access to
|
||||
anybody's record, every API token has to be reissued, every device re-trusted,
|
||||
and both people have to invite and accept again before sharing works. That is
|
||||
the fail-safe direction — access lost, never access resumed — and re-consenting
|
||||
is the right amount of ceremony for handing someone your health record a second
|
||||
time. Plan for it rather than discovering it.
|
||||
Objects are named `YYYY-MM-DD/user-<id>.json.enc`: the record as JSON,
|
||||
gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the
|
||||
object's own name as associated data and the key's id in the header.
|
||||
|
||||
The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where
|
||||
every excluded model carries a written verdict and a structural test refuses to
|
||||
let a new model land without one.
|
||||
The nightly database dump goes into the same bucket, as
|
||||
`database/<host of APP_URL>/healthlog-<time>-daily.dump.enc` with its manifest
|
||||
beside it, sealed the same way. The folder keeps the dumps of instances that
|
||||
share a bucket apart. It is the copy that rebuilds a lost server
|
||||
([disaster-recovery.md](disaster-recovery.md)).
|
||||
|
||||
## Container memory (the nightly off-host job)
|
||||
**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`,
|
||||
`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers
|
||||
them). It deletes only two things: the objects of an account that was erased or
|
||||
wiped, once the grace window has ended, and its own connection-test object. A
|
||||
deletion the bucket refuses is retried every night and shown in Admin →
|
||||
Backups → Off-host. The admin wipe of every account deletes nothing in the
|
||||
bucket.
|
||||
|
||||
This is the part that bites, and it bit the nightly job a release after it bit
|
||||
the weekly one. The job runs inside the app process, so V8's heap limit is the
|
||||
app's heap limit. Node sizes it from the container's memory cap: 1 GB gives a
|
||||
524 MB old-space limit, the compose default of 1536m (since 1.40.1) gives 792 MB. A long-lived Next.js server is already holding a large share
|
||||
of that before the job starts.
|
||||
**Retention.** The app never expires objects by age. Set a lifecycle rule on
|
||||
the bucket that covers both the account objects and `database/` (for example,
|
||||
expire all objects after 30 days); Admin → Backups says when there is none.
|
||||
|
||||
The uploader used to build the whole backup JSON as one string, gzipped
|
||||
that whole string, ran a whole-buffer cipher pass over the result and handed
|
||||
the finished buffer to a single `PutObject` — four full copies of the record
|
||||
alive at once. On an account of 445 000 measurements the JSON alone is 242 MB,
|
||||
and the first configured run took the container down seventeen seconds in with
|
||||
`FATAL ERROR: Reached heap limit`. Because the job shares the app process, one
|
||||
account's size restarted the instance for everybody on it.
|
||||
**Checking it works.** Admin → Backups → Off-host shows, per account, when the
|
||||
last object landed: fresh, due (one night missed), stale (two missed), never,
|
||||
or no record yet. A night that uploaded nothing for anybody fails the job with
|
||||
the bucket's own error. Accounts pending deletion are not uploaded and are not
|
||||
listed. On the 1st of each month at 04:11, a drill downloads the newest object
|
||||
of every account, decrypts it and checks its shape and keys. It restores
|
||||
nothing.
|
||||
|
||||
It streams now. The JSON is produced a page at a time, gzip and the cipher
|
||||
consume it as it arrives, and the object goes up as a multipart upload that
|
||||
holds two 8 MB parts. What the process holds is fixed by that pipeline's shape
|
||||
rather than by the size of the record going through it: measured on the same
|
||||
445 000-measurement account under `--max-old-space-size=450`, the old path
|
||||
died and the new one finished holding tens of megabytes, writing a 9.1 MB
|
||||
object that restores to the identical record.
|
||||
**Rotating the off-host key.** Put the new key in `BACKUP_ENCRYPTION_KEY`, move
|
||||
the old one to `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, restart. Remove the old key
|
||||
once the lifecycle rule has expired every object written under it.
|
||||
|
||||
Up to 1.43.4 that held for the tables that are long in rows, not for the
|
||||
ones that are heavy in bytes. The stored documents and the ECG traces were
|
||||
still read whole, and a vault of 190 MB cost about five times that in heap:
|
||||
the bytes, their base64, the section's JSON and the preview's copies of each
|
||||
row. A single account with that many documents took a 524 MB process down on
|
||||
every weekly run, while 1.25 million measurements beside them fitted easily.
|
||||
Both writers now read the traces a page at a time and the documents one at a
|
||||
time, each document's bytes in slices of 1.5 MB. Measured on that account
|
||||
under `--max-old-space-size=524`, the peak went from 930 MB (a crash) to
|
||||
209 MB. The restore had the same problem in the other direction: it kept
|
||||
every document of the file in memory and peaked at 1.25 GB on the same copy.
|
||||
It now leaves the stored bytes out of its first read and writes the
|
||||
documents one at a time from a second one, and the copy restores every
|
||||
document byte for byte inside the 524 MB heap.
|
||||
## Keys a copy needs
|
||||
|
||||
One ceiling remains, and it is structural rather than a memory bound: a
|
||||
multipart upload carries 10 000 parts, so 80 GB is the largest object one
|
||||
account can produce. Past it the account's backup fails with a clear refusal,
|
||||
is counted in the run's `offhost_backup_oversized` meta, and the pass carries
|
||||
on with everybody else.
|
||||
A copy carries the database's ciphertext as stored, so it needs the
|
||||
application keys it was written under, also after a rotation. Keep a retired
|
||||
`ENCRYPTION_KEYS` entry until no copy you may still restore needs it. **Admin →
|
||||
Encryption → Keys the backups still need** lists them, the database dumps
|
||||
included: a dump needs the key that was active when it was taken, for as long
|
||||
as it is kept on this server or in the bucket. Off-host objects also need
|
||||
`BACKUP_ENCRYPTION_KEY` (or the previous key that wrote them).
|
||||
|
||||
### Reading a failed run
|
||||
## Size and memory
|
||||
|
||||
A nightly run that could not upload for **anybody** now fails the pg-boss job
|
||||
instead of reporting success. This is the case wrong credentials, a missing
|
||||
bucket and an unreachable endpoint all land in, and the target's own sentence
|
||||
rides out as the failure cause — `The request signature we calculated does not
|
||||
match the signature you provided`, `The specified bucket does not exist`,
|
||||
`connect ECONNREFUSED`. Check `offhost_backup_uploaded` against
|
||||
`offhost_backup_total_users`: before this change a run where every single
|
||||
upload failed still read `ok: true`, so a bucket could stay empty while the
|
||||
jobs page looked healthy.
|
||||
Writing, reading and restoring a copy streams: the record goes through a page
|
||||
at a time, documents one at a time, and a stored copy is kept as sealed pieces
|
||||
of about a megabyte. Memory does not grow with the record. Give the app
|
||||
container at least 1 GB.
|
||||
|
||||
A run where SOME account got a copy still succeeds, with the rest counted in
|
||||
`offhost_backup_failed`. Failing the whole queue over one account's object
|
||||
would re-upload everybody's on every retry.
|
||||
`BACKUP_MAX_STORED_MB` (default 2048) caps one account's stored copy. An account
|
||||
past it fails alone, keeps its previous copy, and is counted as
|
||||
`records_oversized`; the message names the setting.
|
||||
|
||||
### A run the process died under
|
||||
|
||||
Both backup jobs carry a pg-boss heartbeat of five minutes. While a run is
|
||||
alive the worker refreshes it; a run whose process died (a restart, or a
|
||||
container killed for memory) stops refreshing, and pg-boss fails the job with
|
||||
`job heartbeat timeout` within minutes instead of leaving it `active` until its
|
||||
two- or four-hour expiry. The retries the queue already allows then pick it up.
|
||||
|
||||
Each pass also notes when it started and when it got past every account
|
||||
(`backup_pass_attempts`). An account whose last attempt started and never
|
||||
finished is the one the process died under, and the next run takes it last, so
|
||||
one record too large for the container cannot keep every account after it from
|
||||
a copy. **Admin console → Backups** shows when the run in progress started and
|
||||
names such an account under both the weekly and the off-host card. Seeing the
|
||||
same account there twice means it needs more memory than the container has.
|
||||
|
||||
### Which account has no copy
|
||||
|
||||
The counts above say how many accounts were uploaded, never which. An account
|
||||
whose object has failed every night for a month reads as `99 uploaded, 1
|
||||
failed` each time, which looks like weather.
|
||||
|
||||
**Admin console → Backups → Off-host copies** answers it per account: when this
|
||||
host last put that account's encrypted copy in the bucket, how big it was, and
|
||||
how that compares with the nightly schedule. **Fresh** is inside one run,
|
||||
**due** means one run produced nothing for that account, **stale** means two
|
||||
did not, **never** means a run walked the account and no object has ever
|
||||
landed for it, and **no record yet** means no run has recorded the account at
|
||||
all. On a host without `BACKUP_S3_*` the card says off-host backup is not
|
||||
configured and stops there.
|
||||
|
||||
The ledger starts empty on the release that adds it, so on a host that has
|
||||
been uploading nightly for months every account reads **no record yet** until
|
||||
the first run after the upgrade. That is the ledger having no history, not the
|
||||
bucket being empty — which is why it is a separate word from **never**, and
|
||||
why the row says so rather than claiming nothing has ever reached the bucket.
|
||||
|
||||
Each verdict carries six hours of slack on top of the schedule's own 24, and
|
||||
that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin,
|
||||
so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the
|
||||
timestamp is when _that account's_ object landed, so on a cohort walked one
|
||||
account at a time an account reached later tonight than last night drifts by
|
||||
hours. Without the slack a healthy host would paint its whole cohort **due**
|
||||
once a year.
|
||||
|
||||
The verdicts come from a ledger the worker writes when the object lands
|
||||
(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's
|
||||
grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card
|
||||
still tells the truth. A row that goes stale while the run reports success is
|
||||
that account's object being refused, and the reason is in the run's
|
||||
`offhost_backup_failures` meta.
|
||||
|
||||
## The weekly in-database backup (`data-backup`)
|
||||
|
||||
Separate from the off-host job above, and easy to confuse with it. A second
|
||||
pg-boss job writes one `WEEKLY_AUTO` copy per user into the database, the same
|
||||
JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` /
|
||||
`ENCRYPTION_KEYS`, staying inside the instance. It is what
|
||||
`/api/admin/backups/<id>/restore` reads, and an uploaded backup file is stored
|
||||
the same way.
|
||||
|
||||
### How a copy is stored
|
||||
|
||||
From v1.39.2 a copy is kept as ordered pieces of about a megabyte in
|
||||
`data_backup_chunks`, with the list of pieces on its `data_backups` row. The
|
||||
writer produces the JSON a page at a time, gzips it as it goes and seals each
|
||||
megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore,
|
||||
preview, download) takes the pieces one at a time. Nothing on either path holds
|
||||
the whole copy, so the size of a copy has nothing to do with the app's memory.
|
||||
Measured on an account of 2.6 million readings in a 1 GB container,
|
||||
with the web server and the worker in one process: the backup took 87 seconds
|
||||
and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the
|
||||
1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every
|
||||
reading exactly. The container peaked at 567 MB and did not restart. What the
|
||||
backup, preview and download hold on top of the running app stays at a few tens
|
||||
of megabytes and does not grow with the account. A 512 MB container is too
|
||||
small for the app itself: its heap limit is 259 MB and a backup of any account,
|
||||
even one with 130 000 readings, runs it out of heap.
|
||||
|
||||
Each piece carries, inside its encryption, which copy it belongs to, its
|
||||
position, and whether it is the last. Before a restore deletes anything, every
|
||||
piece is checked: a piece that is missing, moved, altered, taken from another
|
||||
copy, or a copy that stops short of its last piece is refused with
|
||||
`backup.payload.undecryptable`, and the account is left as it was.
|
||||
|
||||
The write is one transaction. Until the new copy is complete the previous one
|
||||
stays in place and readable; a run that fails halfway leaves it untouched. A
|
||||
second backup of the same account started while one is running (a manual run
|
||||
during the weekly one, say) waits for the first to finish and then replaces
|
||||
its copy; neither leaves a partial copy behind.
|
||||
|
||||
While a restore of an account's copy is queued or running, the weekly and
|
||||
manual backup leave that account's copy alone and back up everyone else. The
|
||||
run's `users_skipped_restoring` meta counts such accounts; the next run
|
||||
replaces the copy as usual. If a copy is replaced anyway while it is being
|
||||
read (a download or preview in progress when the weekly run finishes), the
|
||||
reader answers `409` with `backup_changed` and "The backup was replaced by a
|
||||
newer one while it was being read", not the undecryptable error, and a restore
|
||||
rolls back with nothing changed. Start it again to use the new copy. A
|
||||
download already sending when this happens stops partway; download again.
|
||||
|
||||
Copies written before v1.39.2 are a single value in `data_backups.data` and
|
||||
still restore, preview and download as they did. The next weekly run replaces
|
||||
the weekly copy in the new form, and a key rotation (below) converts every
|
||||
remaining one, including uploaded copies. Until then those older copies are
|
||||
read as one value, as before, which needs the memory they needed when they were
|
||||
written.
|
||||
|
||||
Migration 0352 cannot be undone by going back to v1.39.1. That release reads
|
||||
only the single value, so it finds a copy in pieces empty, and if it writes a
|
||||
weekly copy into a row that has pieces, the row holds both forms. v1.39.2 then
|
||||
refuses to read that row, because nothing says which of the two is current
|
||||
("This backup holds both a single stored value and pieces ..."); the next
|
||||
weekly backup replaces it. If you must go back, restore the database backup
|
||||
you took before upgrading.
|
||||
|
||||
### The size limit
|
||||
|
||||
The one limit left is on storage: a copy of one account may take at most
|
||||
`BACKUP_MAX_STORED_MB` megabytes (default 2048). It exists so a runaway copy
|
||||
fails with a message instead of filling the database volume, which holds the
|
||||
old and the new copy side by side while a backup runs. An account past it fails
|
||||
for that account alone, is counted in the run's `users_failed` and
|
||||
`records_oversized` meta, and keeps its previous copy; the pass carries on with
|
||||
everybody else. The message names the setting. Raise it in `.env`, recreate the
|
||||
app container, and make sure the database volume has room for two copies of
|
||||
that size.
|
||||
|
||||
Before v1.39.2 the limit was a fifth of the app's heap, 105 MB in a 1 GB
|
||||
container, because the copy had to pass through the app as one value. An
|
||||
account with 1.75 million readings was past it, and the only way round was more
|
||||
memory. That is no longer the case: `records_oversized` means the storage limit,
|
||||
never memory.
|
||||
|
||||
The writer deliberately reads no heap gauge. A version that did shipped in
|
||||
v1.38.6 and compared the whole process's live usage, garbage included, against
|
||||
80 % of the limit. A Next.js server that has been up for a week sits at 400 MB
|
||||
of largely collectable heap, so the weekly pass aborted every account on the
|
||||
first chunk, including one whose whole stored copy is 1.2 MB, and reported
|
||||
success while doing it. If you are on v1.38.6, a `data-backup` run that finishes
|
||||
in seconds having backed up nothing is that defect and not your record.
|
||||
|
||||
### Key rotation
|
||||
|
||||
Rotation reaches a stored backup's envelope and nothing inside it. The
|
||||
content of a disaster-recovery backup is the database's ciphertext, carried
|
||||
as it is stored, and a restore writes it back verbatim: a note, a document or
|
||||
a coach message inside a copy taken before a rotation still needs the key the
|
||||
rotation retired. The same holds for every copy in the off-host bucket and for
|
||||
every file an operator has downloaded. So the rule is: **keep a retired
|
||||
`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.**
|
||||
**Admin → Encryption → Keys the backups still need** lists, per key id, the
|
||||
stored copies that need it with the oldest date, and when the last off-host
|
||||
copy needing it expires under the bucket's lifecycle rule. Copies written
|
||||
before v1.39.3 did not record their keys and are listed as such.
|
||||
|
||||
Every restore, the restore preview, the admin upload and the monthly drill
|
||||
check this before anything is changed: a file whose content needs a key this
|
||||
server does not have (or an id whose key material differs) is refused with the
|
||||
key id named, and the account is left as it was.
|
||||
|
||||
Re-encrypting the content of stored backups during rotation was considered and
|
||||
not done: it would mean decrypting, parsing, re-encrypting and re-writing every
|
||||
copy in full, and it still could not reach the off-host copies or downloaded
|
||||
files, so the rule above would be needed regardless.
|
||||
|
||||
`scripts/rotate-encryption-key.ts` and the rotation in the admin console
|
||||
re-seal every piece under the active key, in small batches, without reading
|
||||
the backup inside. The link between a piece, its copy and its position is
|
||||
inside the encryption, so it comes through rotation unchanged.
|
||||
|
||||
A copy still in the older single-value form (any of them, including the
|
||||
`~hlgcm1.` form v1.38.6 to v1.39.1 wrote) is not re-sealed in place, which would need the
|
||||
whole copy in memory several times over. Rotation converts it into pieces
|
||||
under the active key instead, one copy at a time, reading the stored value a
|
||||
few megabytes at a time; the copy keeps its date. The conversion of a copy is
|
||||
one transaction, so a copy that fails its check is left exactly as it was and
|
||||
counted as an error. Rotation up to v1.39.1 counted every `~hlgcm1.` copy
|
||||
as an error; run it again after updating before retiring the old key.
|
||||
|
||||
### What a restore replaces
|
||||
|
||||
A restore replaces the account's data tables; it does not merge into them.
|
||||
Every row the file carries is written back under its original id, and every row
|
||||
the account gained after the snapshot was taken — readings, doses, mood
|
||||
entries, documents — is deleted with the rest of the class before the file is
|
||||
read back. Restoring Monday's copy on Wednesday therefore costs the account
|
||||
everything it recorded on Tuesday. If the current state is worth keeping, take
|
||||
a fresh snapshot from the backups console before restoring the old one.
|
||||
|
||||
## Rotating the off-host key
|
||||
|
||||
`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate
|
||||
it, set the new key there and move the old one into
|
||||
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then
|
||||
restart. New objects are written under the new key; older objects stay
|
||||
readable because every version-4 object names its key and older objects are
|
||||
tried against each key in turn. Remove the old key once the lifecycle rule has
|
||||
retired every object written under it, that is `BACKUP_RETENTION_DAYS` after
|
||||
the restart.
|
||||
|
||||
## Monthly restore drill (automatic)
|
||||
|
||||
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
|
||||
04:11 on the 1st of each month) exercises the read path end-to-end:
|
||||
fetch every backup object of the most recent date from the bucket (one per
|
||||
account), decrypt each under the current `BACKUP_ENCRYPTION_KEY`, JSON-parse
|
||||
it, and sanity-check the payload shape. It performs **no database restore** —
|
||||
it validates the artefacts, not the import path. Each account is checked and
|
||||
reported on its own: one object that fails is named, and the others are still
|
||||
read.
|
||||
|
||||
Outcomes:
|
||||
|
||||
- **Success** — record counts, object age, and sizes land in the
|
||||
wide-event meta (`job.restore_drill`).
|
||||
- **Stale chain** — the newest object is older than 3 days: the nightly
|
||||
uploader has stalled (or the lifecycle rule is too aggressive). The
|
||||
drill pages via the worker error reporter (stderr + GlitchTip).
|
||||
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
|
||||
rotated key), malformed JSON: pages the same way, once per failing object,
|
||||
with the object key and how many accounts were checked. A decryption
|
||||
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
|
||||
key cannot read the existing objects: put the old key into
|
||||
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
|
||||
- **Content under a missing application key** — the object opens, but its
|
||||
content was written under an `ENCRYPTION_KEYS` entry this server no longer
|
||||
has, or under an id whose key material changed. The drill decrypts the
|
||||
shortest value of each section under every key id it finds, and pages naming
|
||||
the key when none of them opens. A restore of that object
|
||||
would write back rows nobody can open; put the key back before restoring.
|
||||
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
|
||||
silently (wide-event warning only).
|
||||
|
||||
The drill lists the bucket to find the newest object, so it needs
|
||||
`ListBucket` on top of the uploader's `PutObject`, `GetObject` and
|
||||
`AbortMultipartUpload`. Cloudflare R2's **Object Read & Write** token covers
|
||||
all four.
|
||||
Both backup passes note which account they are on. An account whose last
|
||||
attempt never finished (the process died under it) is taken last on the next
|
||||
run and named in Admin → Backups, so one record too large for the container
|
||||
cannot keep everyone else from a copy.
|
||||
+167
-39
@@ -13,11 +13,13 @@ Migrations land **before** the new app container takes traffic. The
|
||||
canonical command sequence on a single-instance host:
|
||||
|
||||
```sh
|
||||
# 0. Dump the database first. A migration is not undone by going back to
|
||||
# the previous image; the dump is the way back. See "Updates" in
|
||||
# docs/self-hosting/getting-started.md.
|
||||
docker compose exec -T db pg_dump -U healthlog healthlog \
|
||||
| gzip > healthlog-before-update-$(date +%F).sql.gz
|
||||
# 0. Nothing to do by hand for the way back: when the new image brings
|
||||
# migrations, its entrypoint first writes
|
||||
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
|
||||
# volume) and keeps the last two. A migration is not undone by going back
|
||||
# to the previous image; that dump is the way back
|
||||
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
|
||||
# Copy it off the host if the host itself is what you are worried about.
|
||||
|
||||
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
|
||||
# set it to the new one first.
|
||||
@@ -33,6 +35,15 @@ docker compose run --rm app sh -c 'exit 0'
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Step 2 takes the pre-migration dump too, in the one-off container, before it
|
||||
migrates. When the backups directory is on its volume and that dump fails (a
|
||||
full disk, most often), the entrypoint stops before migrating and says so;
|
||||
free the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false`
|
||||
for one start without the dump. Without the `backups` volume the directory
|
||||
is not writable, or it is the image's own and sits inside the container that
|
||||
the update replaces; either way the log carries a warning, no dump is taken,
|
||||
and the migrations run.
|
||||
|
||||
Use `docker compose run` rather than a plain `docker run` for step 2. A
|
||||
plain `docker run` starts outside the stack's network, where `db` does not
|
||||
resolve, and the entrypoint gives up after thirty seconds with "Database
|
||||
@@ -109,6 +120,66 @@ that for you, and each boot retries). Anything else is a session you can
|
||||
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
|
||||
You never need to run `migrate resolve` by hand for this case.
|
||||
|
||||
### A concurrent index build that failed
|
||||
|
||||
A migration that builds an index `CONCURRENTLY` (0477 is one) and fails
|
||||
half-way, on a constraint violation, a cancel or a killed container, leaves an
|
||||
INVALID index behind and its attempt on record as failed. Once you have mended
|
||||
the cause and run `prisma migrate resolve --rolled-back <migration>`, the next
|
||||
boot drops that INVALID index before it migrates, logs
|
||||
`dropped the invalid index …`, and the re-run builds it again. Without that
|
||||
step the re-run would find the index by name and skip it.
|
||||
|
||||
A build that is still running looks the same, INVALID with its migration not
|
||||
yet finished. The boot therefore leaves every index alone while another
|
||||
instance is migrating (it checks Prisma's migration lock first) and never
|
||||
drops an index Postgres reports as being built, so a second container that
|
||||
starts during the first one's migration cannot cancel it.
|
||||
|
||||
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.
|
||||
|
||||
### v1.46.0 notes
|
||||
|
||||
- **Postgres 18.** The compose file moves the bundled database from
|
||||
Postgres 16 to 18. The first `docker compose up -d` with the new compose
|
||||
file runs `db-upgrade`, which copies the database into a new
|
||||
`postgres-data` volume, checks every table's row count, and only then lets
|
||||
the app start; the old `pgdata` volume stays as it was, as the way back.
|
||||
Pulling only the new image with the old compose file keeps 16, which this
|
||||
release still runs on. Details, rollback and the steps for a database you
|
||||
run yourself: [postgres-major-upgrade.md](postgres-major-upgrade.md).
|
||||
- Migration `0485` drops `preview_features_json` from `users`, the same
|
||||
pattern as `0430` in v1.44.0 below: do not run the previous image beside
|
||||
the new schema. The sequence above stops the old container first; a
|
||||
web/worker split stops both roles before the first new container starts.
|
||||
- Migration `0487` drops four columns from `app_settings`
|
||||
(`encryption_key_backup_confirmed_at`, `_key_id`, `_fingerprint` and
|
||||
`_by_user_id`) after carrying the confirmation they held into the new
|
||||
`recovery_kit_confirmations` table. The previous image reads those columns
|
||||
on nearly every request that loads the instance settings and fails once
|
||||
they are gone, so the same rule holds: never run it beside the new schema.
|
||||
- The other migrations create tables (`0486`, `0487`), add columns
|
||||
(`0480`, `0482`, `0483`, `0488`, `0489`) and build two indexes: `0477`
|
||||
concurrently on `measurements`, `0488` without `CONCURRENTLY` on `users`,
|
||||
which is small enough that the short lock does not matter. Three touch
|
||||
rows: `0476` links API-written measurements to their token on accounts
|
||||
that have exactly one measurement token, and `0478`/`0479` give PDFs whose
|
||||
preview or text read failed one more attempt. On a prod-sized copy all of
|
||||
them together finished in under a second.
|
||||
- **Rolling back to v1.45 while accounts are pending deletion.** v1.46 keeps
|
||||
a deleted account, a deleted managed profile and a data wipe's copy for a
|
||||
grace window (`0488`). v1.45 knows none of that: an account pending
|
||||
deletion can sign in again with its password and every background job runs
|
||||
for it again, nothing ever erases it, a wipe's off-host purge held to the
|
||||
end of its window runs at once, and v1.45's wipe deletes the copies taken
|
||||
before earlier wipes. Before going back, list the accounts with
|
||||
`SELECT id, username, deletion_purge_at FROM users WHERE deletion_requested_at IS NOT NULL;`
|
||||
and either undo each deletion in the admin users list or let the purge
|
||||
finish them first; after coming forward again, delete any that should
|
||||
still go.
|
||||
|
||||
### v1.45.0 notes
|
||||
|
||||
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
|
||||
@@ -186,10 +257,14 @@ roll back:
|
||||
app version reads forward-compatible-ish shapes for nearly every
|
||||
release pair shipped on the v1.4.x line.
|
||||
3. If a column-drop migration ships in the rollback target, hand-roll
|
||||
the column back via a fresh forward migration (`pnpm prisma migrate
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
the column back via a fresh forward migration
|
||||
(`pnpm prisma migrate dev --create-only --name vX_Y_Z_rollback_<col>`),
|
||||
edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first. The columns each
|
||||
release drops are named in its notes above: for v1.46.0 that is
|
||||
`users.preview_features_json` (`0485`) and the four
|
||||
`app_settings.encryption_key_backup_confirmed_*` columns (`0487`); the
|
||||
key backup confirmation then has to be given again on v1.45.
|
||||
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
|
||||
that saved the Coach lookback "Last year" reads its whole Coach
|
||||
preference blob as defaults on the older version, metric exclusions
|
||||
@@ -197,13 +272,32 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
pick another lookback before the rollback, or re-save their exclusions
|
||||
after it.
|
||||
|
||||
When the data itself has to go back to before the update, restore the
|
||||
pre-migration dump with the previous image; the steps are in
|
||||
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration).
|
||||
|
||||
## Catalog templates
|
||||
|
||||
A new required environment variable, or a change to the compose shape, needs a
|
||||
matching pull request to the TrueNAS apps catalog in the same release cycle
|
||||
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
|
||||
`docs/self-hosting/portainer/templates.json`). See
|
||||
`docs/self-hosting/truenas.md` for what the catalog template carries.
|
||||
The TrueNAS catalog bot follows new image tags on its own, so a routine
|
||||
release needs nothing there. These changes do need a matching pull request to
|
||||
`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and
|
||||
an update to `docs/self-hosting/unraid/healthlog.xml` and
|
||||
`docs/self-hosting/portainer/templates.json`:
|
||||
|
||||
- a new required environment variable, or a new format or minimum for an
|
||||
existing one (the form validates the secrets);
|
||||
- a changed listening port, health check path or health semantics, or start
|
||||
time beyond the template's five-minute start period;
|
||||
- a required PostgreSQL major version, or an extension the stock `postgres`
|
||||
image does not ship;
|
||||
- any other change to the compose shape: a new container, a volume the app
|
||||
must write to, a different user.
|
||||
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`,
|
||||
`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`,
|
||||
`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it
|
||||
through the form's additional environment variables. See
|
||||
`docs/self-hosting/truenas.md` for the operator side.
|
||||
|
||||
## Verify the served version
|
||||
|
||||
@@ -215,13 +309,31 @@ always confirm the served build after a deploy:
|
||||
```sh
|
||||
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
|
||||
# exits non-zero on a mismatch or timeout after a bounded retry loop.
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
HEALTHLOG_PROD_URL=https://healthlog.example.com \
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
```
|
||||
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
|
||||
is required unless you pass `--only=demo`. Pass `--only=prod` /
|
||||
`--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
The worker loads each job's handler module on that job's first run, so a
|
||||
module the image cannot load does not stop the worker from starting. Once
|
||||
it has bound its queues, the worker loads every handler a single time in
|
||||
the background and reports each one that fails, normally within a minute
|
||||
of start. Each failure is a `job.run.failed` event at `error` with
|
||||
`reason: "handler_load_failed"` and the queue's name, and stderr also gets
|
||||
`[pg-boss] [registry] preloaded N/M job handlers; failed to load: <queues>`.
|
||||
If either shows up after a deploy, the image is broken for those queues,
|
||||
and their jobs will fail when they run. Roll back (see Rollback above)
|
||||
instead of waiting for the retries to run out:
|
||||
|
||||
```sh
|
||||
docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:'
|
||||
```
|
||||
|
||||
## Walk the synthetic journey
|
||||
|
||||
The version check proves the instance serves the right build. It does not
|
||||
@@ -436,29 +548,45 @@ low-friction default.
|
||||
|
||||
## Deploy-status webhook: replay protection
|
||||
|
||||
`POST /api/internal/deploy-webhook` authenticates via the static
|
||||
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
|
||||
enforces a freshness window: a request carrying an
|
||||
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
|
||||
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
|
||||
timestamp lies more than **5 minutes** from server time — so a captured
|
||||
request cannot be replayed later to re-page admins or forge deploy
|
||||
outcomes in the audit log.
|
||||
`POST /api/internal/deploy-webhook` accepts two kinds of request.
|
||||
|
||||
Two modes:
|
||||
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
|
||||
This is what Coolify's stock notification sender can do. It does not
|
||||
protect against a replay: anyone who captures one request holds the
|
||||
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
|
||||
beside it is still checked (a stale or unparseable one is refused with
|
||||
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
|
||||
staleness hint. Versions before 1.46 described it as replay protection.
|
||||
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
|
||||
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
|
||||
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
|
||||
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
|
||||
**5 minutes** of server time. A signed request sends no secret, and a
|
||||
captured one is useless once the window has passed.
|
||||
|
||||
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
|
||||
stock notification sender does not attach one. A header that is
|
||||
present but stale or unparseable is always rejected.
|
||||
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
|
||||
environment to make the header mandatory, closing the replay window
|
||||
completely. Use this when the sender (or a reverse proxy in front of
|
||||
the app) can attach the timestamp, e.g.:
|
||||
Two settings tighten this:
|
||||
|
||||
```sh
|
||||
curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-H "X-Deploy-Webhook-Secret: $SECRET" \
|
||||
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"success"}'
|
||||
```
|
||||
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
|
||||
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
|
||||
without it is refused with `401 {"status":"stale"}`. This is what the
|
||||
setting has always meant. It keeps an old capture out, but not someone who
|
||||
saw the secret and sends a new request with a new timestamp.
|
||||
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
|
||||
secret header alone is refused with 401. This is the one that stops a
|
||||
replay. Use it when the sender, or a small relay in front of the app, can
|
||||
sign:
|
||||
|
||||
```sh
|
||||
TS=$(date +%s)
|
||||
BODY='{"status":"success"}'
|
||||
SIG=$(printf '%s.%s' "$TS" "$BODY" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
|
||||
curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-H "X-Deploy-Webhook-Timestamp: $TS" \
|
||||
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
Both may be set; a signed request satisfies both. An operator who set the
|
||||
timestamp flag before 1.46 keeps exactly the behaviour they had.
|
||||
@@ -0,0 +1,231 @@
|
||||
# Disaster recovery
|
||||
|
||||
The copy that rebuilds a lost server is the database dump. This page says
|
||||
what is dumped and where it goes, which keys a restore needs, how to restore
|
||||
onto a new server, and how the procedure is tested.
|
||||
|
||||
## What is backed up, and where
|
||||
|
||||
| Copy | When | Where | Kept |
|
||||
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
|
||||
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
|
||||
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/<host of APP_URL>/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
|
||||
|
||||
A dump is `pg_dump --format=custom` of the whole database: every account,
|
||||
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
|
||||
queue and the instance settings. It is two files:
|
||||
|
||||
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
|
||||
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
|
||||
app version and build, the newest applied migration, the Postgres version,
|
||||
the archive's size and SHA-256, the row count of every table, and the
|
||||
fingerprints (never the values) of the keys the database needs.
|
||||
|
||||
A dump counts as good only when both files exist under their final names. The
|
||||
archive is written as `.partial`, read back with `pg_restore --list`, and
|
||||
renamed after its manifest; a run that fails leaves nothing that looks good.
|
||||
The admin backups page shows the newest good dump, where it is, and the
|
||||
newest failed run. The start log says on every boot where dumps go, or that
|
||||
none can be kept.
|
||||
|
||||
Local dumps are not encrypted. They sit beside the database volume, in the
|
||||
same trust zone: whoever can read one volume can read the other. The columns
|
||||
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
|
||||
Keep a copy of the dumps off the host; the off-host bucket does that for you,
|
||||
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
|
||||
per-account copies use.
|
||||
|
||||
A dump holds every account that existed when it was taken, including one
|
||||
deleted since. Deleted accounts leave the dumps as they age out: after
|
||||
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
|
||||
bucket. A pre-migration dump goes once it is older than every nightly dump
|
||||
kept, except the one taken before the latest update: that one is the way back
|
||||
from it and stays until the next update writes a newer one. On an install
|
||||
that rarely updates, delete it by hand once you no longer want the way back,
|
||||
or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin
|
||||
backups page reads the bucket's rules and says when none does, and the
|
||||
recovery readiness card calls it attention and tells the admins once. This is the
|
||||
retention period to state where you tell people that deleting their account
|
||||
deletes their data.
|
||||
|
||||
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
|
||||
being replaced: a mounted volume or bind mount, or any writable directory when
|
||||
HealthLog does not run in a container. The image creates the default
|
||||
directory, so an install that runs it without the `backups` volume can write
|
||||
there, but the directory sits in the container's own layer and the next update
|
||||
throws it away. HealthLog reads the container's mount table to tell the two
|
||||
apart and treats such a directory, or one on a tmpfs, like one it cannot
|
||||
write: the nightly dump is uploaded off-host and nothing is kept on the
|
||||
server, and no dump is taken before migrations; with no bucket either, no
|
||||
dump is taken at all. The start log, the admin backups page and the recovery
|
||||
readiness card say so.
|
||||
|
||||
The per-account copies (the weekly in-database copy, the nightly per-account
|
||||
objects) roll one account back on the same server. They cannot rebuild a
|
||||
server; see [`backup-restore.md`](backup-restore.md).
|
||||
|
||||
## The keys a restore needs
|
||||
|
||||
| Key | Without it |
|
||||
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
|
||||
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
|
||||
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
|
||||
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
|
||||
|
||||
The restore compares the keys in its environment with the fingerprints in the
|
||||
manifest before it writes anything, and names each key that is missing or
|
||||
different.
|
||||
|
||||
## Restore onto a new server
|
||||
|
||||
1. Install Docker and take the `docker-compose.yml` of the version that wrote
|
||||
the dump or a newer one. The manifest's `appVersion` names it; to run that
|
||||
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
|
||||
2. Write `.env` with the keys from your records (see above) and a new
|
||||
`POSTGRES_PASSWORD`.
|
||||
3. Do not run `docker compose up` yet. The app would create an empty schema,
|
||||
and the restore only writes into a database with no tables. Start only the
|
||||
database:
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
4. Restore. When the host is lost, its local dumps went with it, and the
|
||||
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
|
||||
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
|
||||
`.env` as on the old server, then
|
||||
|
||||
```bash
|
||||
docker compose run --rm db-restore list --from-bucket
|
||||
docker compose run --rm db-restore latest --from-bucket
|
||||
```
|
||||
|
||||
`latest --from-bucket` downloads the newest dump of this instance whose
|
||||
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
|
||||
and restores. Name a dump instead of `latest` to take an older one.
|
||||
Each instance uploads under its own folder, `database/<host of APP_URL>/`,
|
||||
so set `APP_URL` as on the old server. A bucket shared with another
|
||||
instance holds its dumps too: `latest` refuses rather than take one of
|
||||
those, and `list --from-bucket` marks them. To restore one on purpose
|
||||
(after moving to a new domain, the old one), add
|
||||
`--instance=<old host>`, or name the dump by its full key,
|
||||
`database/<host>/healthlog-….dump.enc`.
|
||||
|
||||
The opened copy is a whole database, decrypted, in a scratch directory
|
||||
beside the backups. The restore removes it when it ends, on Ctrl-C and
|
||||
`docker compose stop` too. One left by a restore that was killed outright
|
||||
is removed when the next restore starts, and by the nightly retention.
|
||||
|
||||
With the files in hand instead (copied off the host, or downloaded from
|
||||
the bucket with your own tools: both `<name>.dump.enc` and
|
||||
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
|
||||
them in a directory such as `./restore`, make them readable for the
|
||||
container (`chmod a+r restore/*`) and name the dump:
|
||||
|
||||
```bash
|
||||
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
|
||||
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
|
||||
/restore/healthlog-20261024T233000Z-daily.dump
|
||||
```
|
||||
|
||||
Either way it checks the archive against the manifest's SHA-256, the keys
|
||||
against the manifest's fingerprints, and that the database is empty. Then
|
||||
it restores in one transaction, gathers planner statistics and counts
|
||||
every table against the manifest. A count that differs empties the
|
||||
database again and fails. Wait for the line that starts with `Restored`.
|
||||
|
||||
5. Start the stack and check it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl -s http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
Sign in. The app's own key check runs at start and refuses to serve on a
|
||||
key that does not open the data.
|
||||
|
||||
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`.
|
||||
|
||||
`--ignore-key-mismatch` restores although the active encryption key differs.
|
||||
Use it only when that key is lost for good: everything encrypted stays
|
||||
unreadable.
|
||||
|
||||
## Going back to before a migration
|
||||
|
||||
The entrypoint dumps the database before it applies migrations. To return to
|
||||
that state (the previous image, the data as it was before the update):
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
# Keep the current state too, in case it is wanted later.
|
||||
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
|
||||
docker compose exec db dropdb -U healthlog healthlog
|
||||
docker compose exec db createdb -U healthlog healthlog
|
||||
docker compose run --rm db-restore list
|
||||
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
|
||||
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The dump before a migration is taken while the new image starts. If it fails
|
||||
on a directory that keeps it (a full disk, most often), the start stops before
|
||||
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
|
||||
starts without it, once.
|
||||
|
||||
## Knowing the copies are there
|
||||
|
||||
Nobody opens the backups page until the day it matters, so the server tells
|
||||
the admins instead:
|
||||
|
||||
- The **Recovery readiness** card, on the admin overview and on Admin,
|
||||
Backups, judges every copy in one verdict each: the database dump (fresh,
|
||||
failed, or kept nowhere that survives the container), the weekly account
|
||||
copies, the off-host copies, the last restore drill, whether the bucket is
|
||||
set up, whether its lifecycle rule ever removes the dumps under
|
||||
`database/`, and the recovery kit.
|
||||
- The **recovery kit** under Admin, Encryption lists every key a restore
|
||||
needs (the table above), by id and fingerprint, and lets an admin check a
|
||||
saved copy and confirm it. It is due again after any key change.
|
||||
- An hourly check sends each admin a **Backup health** notification
|
||||
(`BACKUP_HEALTH`) when a signal on the card turns to needs attention or at
|
||||
risk, a reminder once a day while one stays at risk, and one more when it
|
||||
recovers. Each admin chooses the channels for it under Notifications.
|
||||
|
||||
[Getting started](../self-hosting/getting-started.md#save-the-recovery-kit)
|
||||
walks through saving the kit.
|
||||
|
||||
## How this is tested
|
||||
|
||||
`scripts/compose-dr-drill.sh` plays this page against real containers: it
|
||||
builds the image, seeds several accounts (the demo account with documents
|
||||
and encrypted notes, a Guardian with a managed profile), lets the scheduled
|
||||
job take the dump and upload it to an S3-compatible bucket, destroys every
|
||||
volume of the stack (the local dumps included), and restores on a new stack
|
||||
given only the bucket and the keys
|
||||
(`docker compose run --rm db-restore latest --from-bucket`). A second run
|
||||
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
|
||||
every table against the manifest and boot the app: health, sign-in, an
|
||||
encrypted note read back, a document opened, the Guardian still seeing the
|
||||
profile. It runs in CI nightly and on every change to the
|
||||
compose file, the image or the dump and restore code
|
||||
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
|
||||
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
|
||||
and a target that is not empty.
|
||||
|
||||
Test your own copies too: once, and after any change to how the server is
|
||||
run, restore last night's dump on a spare machine with the steps above.
|
||||
|
||||
## Settings
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
|
||||
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
|
||||
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
|
||||
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. |
|
||||
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
|
||||
@@ -137,10 +137,15 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
> So before step 4, also check **Admin → Encryption → Keys the backups still
|
||||
> need**. It lists, per key id, the stored copies that need it with the oldest
|
||||
> date, and when the last off-host copy needing it expires under the bucket's
|
||||
> lifecycle rule. Keep the old key in `ENCRYPTION_KEYS` until it is no longer
|
||||
> listed there (the weekly copy replaces itself within a week; delete old
|
||||
> uploaded copies you no longer want, and wait out the off-host retention), or
|
||||
> accept that those copies cannot be restored. Copies written before v1.39.3
|
||||
> lifecycle rule. The database dumps are listed too: a dump is restored only
|
||||
> with the key that was active when it was taken (`db-restore` refuses
|
||||
> without it), so the old key stays listed while a nightly or pre-migration
|
||||
> dump taken under it is on this server, and until the bucket's rule for
|
||||
> `database/` has expired the off-host copies. Keep the old key in
|
||||
> `ENCRYPTION_KEYS` until it is no longer listed there (the weekly copy
|
||||
> replaces itself within a week; delete old uploaded copies you no longer
|
||||
> want, and wait out the dump and off-host retention), or accept that those
|
||||
> copies cannot be restored. Copies written before v1.39.3
|
||||
> did not record their keys and are listed as such: treat them as needing
|
||||
> every key that existed when they were written.
|
||||
>
|
||||
|
||||
@@ -96,8 +96,9 @@ maintenance refuses to start while one is.
|
||||
|
||||
Have at least twice the largest size free on the database volume.
|
||||
|
||||
- **Take a backup** (Settings, Data, Backups), as before any operation on the
|
||||
whole table.
|
||||
- **Take a backup**: a database dump, as in
|
||||
[Updates](../self-hosting/getting-started.md#updates), as before any
|
||||
operation on the whole table.
|
||||
|
||||
## Starting it
|
||||
|
||||
@@ -106,10 +107,12 @@ In the admin area, or with a signed-in admin session:
|
||||
```sh
|
||||
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
|
||||
-H 'content-type: application/json' \
|
||||
--cookie 'healthlog_session=<session>' \
|
||||
--cookie '__Host-healthlog_session=<session>' \
|
||||
-d '{"vacuum": true, "reindex": true}'
|
||||
```
|
||||
|
||||
On a plain-HTTP install the cookie is named `healthlog_session`.
|
||||
|
||||
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
|
||||
run is already queued or running. A Bearer token is refused: the route is
|
||||
cookie-only, like every admin route. Without a background worker the route
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Moving the database to a newer Postgres major
|
||||
|
||||
From v1.46 the compose file runs Postgres 18. Installs from v1.45 or earlier
|
||||
ran 16, and a major version cannot open the data directory of an older one,
|
||||
so the compose file carries a one-time step that moves the database across
|
||||
on the first start after the update. Nothing needs doing by hand on a stack
|
||||
that runs the project's `docker-compose.yml`.
|
||||
|
||||
Installs that bring their own Postgres (Unraid, an existing database server)
|
||||
are not touched: HealthLog runs on 16 and 18 alike, and the move there is
|
||||
yours to schedule with the steps under [By hand](#by-hand).
|
||||
|
||||
## What happens on the first start
|
||||
|
||||
`db-upgrade` is a short-lived container next to `db`. It starts after the new
|
||||
Postgres 18 database is up and before the app, and it exits in a second when
|
||||
there is nothing to move. When the old `pgdata` volume holds a Postgres 16
|
||||
database and the new one is empty, it:
|
||||
|
||||
1. checks that the database service runs Postgres 18, and that there is room
|
||||
on the disk for a second copy and for the dump the app takes before its
|
||||
first migration;
|
||||
2. starts Postgres 16 inside its own container on the old volume, reachable
|
||||
only from inside that container;
|
||||
3. streams a dump into Postgres 18 in a single transaction;
|
||||
4. compares the row count of every table between the two;
|
||||
5. gathers the planner statistics, which a restore does not carry;
|
||||
6. stops Postgres 16 and leaves a marker file on the old volume.
|
||||
|
||||
The app starts once that succeeded, applies the release's migrations and
|
||||
serves as before. On a typical personal record the whole move takes seconds;
|
||||
a database of a few gigabytes takes a few minutes. Watch it with
|
||||
`docker compose logs -f db-upgrade`.
|
||||
|
||||
If any step fails, the new database stays empty, the app does not start, and
|
||||
the log says what happened. A move cut short by a restart or a full disk is
|
||||
run again from scratch on the next `docker compose up -d`.
|
||||
|
||||
The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
|
||||
during the move, so it writes a checkpoint there (and recovers first after an
|
||||
unclean stop), and the marker file is added; no data in it changes. That is
|
||||
the way back.
|
||||
|
||||
## Going back
|
||||
|
||||
Stop the stack, put the two lines of the `db` service back the way v1.45 had
|
||||
them, and start it with the v1.45 image:
|
||||
|
||||
```yaml
|
||||
db:
|
||||
image: postgres:16.15-alpine
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d
|
||||
```
|
||||
|
||||
Anything written after the move lives only in the Postgres 18 volume.
|
||||
`db-upgrade` can stay in the file: it sees that the database service runs
|
||||
Postgres 16 and leaves that volume alone. This also holds after a move that
|
||||
stopped half-way.
|
||||
|
||||
## Updating again after going back
|
||||
|
||||
If you used Postgres 16 again after going back, the Postgres 18 volume holds
|
||||
the state of the day of the move, not what you wrote since. `db-upgrade`
|
||||
notices that the old database changed after the move and refuses to start on
|
||||
the old copy. To move the current 16 database, remove the old copy and the
|
||||
marker, then start again:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm <project>_postgres-data
|
||||
docker compose run --rm --entrypoint rm db-upgrade \
|
||||
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker volume ls` shows the volume's full name. Removing it deletes what was
|
||||
written on Postgres 18 before you went back; take a dump of it first if any
|
||||
of that matters.
|
||||
|
||||
## Afterwards
|
||||
|
||||
Keep the old volume until the new database has run for a while, then remove
|
||||
it. It is a complete copy of the database as it stood before the move, and
|
||||
nothing that happens afterwards reaches it: an account deleted, a document
|
||||
purged from the trash, or a key rotated after a leak is still there in the
|
||||
old volume, and so is everything else for anyone who can read the host's
|
||||
Docker volumes. Removing it is part of the move, not optional tidying.
|
||||
`docker volume ls` shows its full name, prefixed with the compose project:
|
||||
|
||||
```bash
|
||||
docker volume rm <project>_pgdata
|
||||
```
|
||||
|
||||
`db-upgrade` then finds nothing to move. A later release drops the service
|
||||
and the `pgdata` line.
|
||||
|
||||
## Starting over
|
||||
|
||||
When the move has run and the Postgres 18 volume is empty again (deleted by
|
||||
hand, say), `db-upgrade` refuses to import the old state a second time, since
|
||||
that would quietly bring back the database as it was on the day of the move.
|
||||
To do exactly that on purpose, remove the marker and start again:
|
||||
|
||||
```bash
|
||||
docker compose run --rm --entrypoint rm db-upgrade \
|
||||
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## By hand
|
||||
|
||||
For a Postgres you run yourself, or a compose file you have changed so much
|
||||
that the step does not fit, the move is a dump and a restore. Take the
|
||||
encryption key's copy out of its safe place first: the dump is unreadable
|
||||
without it, and this is the moment you would find out.
|
||||
|
||||
```bash
|
||||
# 1. Stop the app so nothing writes while the dump runs (and app-worker, if
|
||||
# you run the worker role in its own container).
|
||||
docker compose stop app
|
||||
|
||||
# 2. Dump the database in Postgres' custom format.
|
||||
docker compose exec -T db pg_dump -U healthlog -Fc healthlog \
|
||||
> healthlog-pg16-$(date +%F).dump
|
||||
|
||||
# 3. Stop the old database. Its volume stays untouched.
|
||||
docker compose stop db
|
||||
```
|
||||
|
||||
4. Point the `db` service at Postgres 18 and a fresh volume. From 18 on, the
|
||||
image keeps its data under `/var/lib/postgresql/18/docker` and expects the
|
||||
volume mounted one level up, at `/var/lib/postgresql`; mounted at
|
||||
`.../data` it refuses to start with "there appears to be PostgreSQL data
|
||||
in: /var/lib/postgresql/data".
|
||||
|
||||
```bash
|
||||
# 5. Start the new database and restore the dump into it.
|
||||
docker compose up -d db
|
||||
docker compose exec -T db pg_restore -U healthlog -d healthlog --no-owner \
|
||||
< healthlog-pg16-<date>.dump
|
||||
|
||||
# 6. Gather the planner statistics. A restore brings the rows but not the
|
||||
# statistics, and until autovacuum gets round to every table the planner
|
||||
# guesses at their sizes, which can turn a sub-second page into one that
|
||||
# takes many seconds.
|
||||
docker compose exec -T db vacuumdb -U healthlog -d healthlog --analyze-in-stages
|
||||
|
||||
# 7. Start the app and confirm it.
|
||||
docker compose up -d app
|
||||
curl -s http://localhost:3000/api/version
|
||||
```
|
||||
|
||||
The start log should say there are no pending migrations beyond the release's
|
||||
own: the dump carried the migration history with it.
|
||||
|
||||
If you had run `CREATE EXTENSION pg_stat_statements;` for
|
||||
[query statistics](query-statistics.md), the dump carries it and the compose
|
||||
`command:` still loads the library, so nothing needs doing there.
|
||||
|
||||
`pg_upgrade --link` is faster on a very large database, but it needs both
|
||||
majors' binaries in one container; for a personal health record the dump is
|
||||
seconds to minutes and has nothing to set up.
|
||||
@@ -214,11 +214,15 @@ as something to log in as.
|
||||
or delete the profile under a fresh second factor. Deleting the profile
|
||||
deletes everything recorded for it.
|
||||
- A guardian may keep that record's settings: modules, units, thresholds,
|
||||
its language and timezone, and its notification preferences. Connected
|
||||
services are visible as a status only. Connect, callback, sync,
|
||||
credential, disconnect, resume and test are not guardian surfaces in
|
||||
this release, and their deep links land on the shared-record unavailable
|
||||
page rather than on a raw refusal.
|
||||
its language and timezone, and its notification preferences. A guardian
|
||||
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
|
||||
Strava and Google Health from its integrations page; the data lands in the
|
||||
profile's record, and a profile without its own provider app uses the
|
||||
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
|
||||
connected for a profile. Sync, the connection test and provider
|
||||
credentials are not guardian surfaces: once connected, the record syncs on
|
||||
its own schedule, and their deep links land on the shared-record
|
||||
unavailable page rather than on a raw refusal.
|
||||
- **Its reminders go to its guardians.** A record with no login has no
|
||||
channels of its own, so medication reminders, measurement reminders,
|
||||
safety-floor alerts and low-stock alerts fan out to every active
|
||||
|
||||
@@ -82,13 +82,18 @@ limit for a document).
|
||||
|
||||
- Imports count against the person's normal upload allowance (60 an hour) and
|
||||
storage quota.
|
||||
- A document that is already in HealthLog is not stored twice, and one the
|
||||
person deleted in HealthLog is not brought back by picking it again. Each
|
||||
import is keyed by system, instance (the archive's origin) and document id,
|
||||
so the same id on two Paperless-ngx instances is two documents; a key
|
||||
imported before v1.39.3 carries no instance and matches any instance. These
|
||||
are the same rules the document token and `scripts/import-documents.mjs`
|
||||
follow.
|
||||
- A document that is already in HealthLog is not stored twice. Each import is
|
||||
keyed by system, instance (the archive's origin) and document id, so the
|
||||
same id on two Paperless-ngx instances is two documents; a key imported
|
||||
before v1.39.3 carries no instance and matches any instance. These are the
|
||||
same rules the document token and `scripts/import-documents.mjs` follow.
|
||||
- A document the person deleted in HealthLog is never brought back by the
|
||||
document token or the import script, however often they re-send it. The
|
||||
person can bring it back themselves: from Recently deleted on the Documents
|
||||
page for 30 days, or by picking it again in the picker, which says "In Recently
|
||||
deleted" or "Deleted in HealthLog" on such a row. Picking one still in
|
||||
Recently deleted restores that same document; picking one already removed
|
||||
for good imports it again.
|
||||
- A picked document follows the person's own automatic AI reading setting.
|
||||
- Only the owner of a record can connect an archive or import from it. People
|
||||
with shared access to a record, whatever their level, do not see the picker.
|
||||
|
||||
@@ -86,10 +86,11 @@ backfill button in settings fetches both for the range it is given.
|
||||
Since v1.42 the home, each dated location period and each stored day keep
|
||||
their coarse location sealed with the instance's encryption key (one value
|
||||
per row, `{ lat, lon, label }`, under the label
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
|
||||
rows written by older releases and clears their readable columns; the
|
||||
readable columns are dropped in a later release once the backfill reports
|
||||
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
|
||||
rows written by older releases and cleared their readable columns, and
|
||||
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
|
||||
still holds a readable location
|
||||
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
in-app rotation) covers all three.
|
||||
|
||||
What the person sees is unchanged: the settings page and
|
||||
|
||||
@@ -9,8 +9,7 @@ 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
|
||||
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
|
||||
- **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
|
||||
@@ -23,11 +22,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
below the supported floor. On a busy instance, raise it.
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
The opt-in environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
|
||||
weather and daylight; it stays off until you
|
||||
enable it and set a home location, and every outbound call goes
|
||||
through the SSRF-guarded egress wrapper.
|
||||
The environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
|
||||
`geocoding-api.open-meteo.com` for weather, air quality and the city
|
||||
search. It is on by default, but nothing leaves the host until someone
|
||||
sets a home location, and every outbound call goes through the
|
||||
SSRF-guarded egress wrapper.
|
||||
Three requests happen by default without anything connected: the
|
||||
HaveIBeenPwned range query when a password is set (only the first five
|
||||
characters of the password's SHA-1 are sent), a daily check of the
|
||||
@@ -81,7 +81,9 @@ explicitly in `.env` (see the commented example there).
|
||||
|
||||
### Using an existing or shared Postgres
|
||||
|
||||
HealthLog can live in a database on a Postgres instance you already run.
|
||||
HealthLog can live in a database on a Postgres instance you already run,
|
||||
version 16 or newer; the compose file ships 18, and every release is tested
|
||||
on both.
|
||||
It stores every time as UTC and pins each connection it opens to UTC
|
||||
(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the
|
||||
server's or the database's own `timezone` setting does not matter: an
|
||||
@@ -105,9 +107,12 @@ ALTER DATABASE healthlog SET timezone TO 'UTC';
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Compose starts two services:
|
||||
Compose starts two services and one short-lived step:
|
||||
|
||||
- `db` — PostgreSQL 16 with a named volume for persistence.
|
||||
- `db` — PostgreSQL 18 with a named volume for persistence.
|
||||
- `db-upgrade` — moves a Postgres 16 database from v1.45 or earlier into
|
||||
the new one, once, and exits; on a new install it has nothing to do. See
|
||||
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md).
|
||||
- `app` — HealthLog itself, listening on `localhost:3000`. The image
|
||||
runs `prisma migrate deploy` on boot so the schema lands
|
||||
automatically; subsequent restarts no-op when the database is
|
||||
@@ -265,30 +270,53 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
|
||||
`pull_policy: always` on the `app` service, and verify the running
|
||||
build with `GET /api/version` (it returns `version` + `buildSha`).
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
|
||||
Without it, the encrypted parts of the database and every in-database backup
|
||||
cannot be read. Keep a copy outside the server, then confirm it under Admin,
|
||||
Encryption: the step shows the key id and a fingerprint (the first 12 hex
|
||||
characters of SHA-256 over the key) and can check a pasted copy without
|
||||
storing it. Admins see a dashboard reminder until the active key is confirmed.
|
||||
No key HealthLog encrypts with is stored in the database. Without them the
|
||||
encrypted parts of the database, a database dump and every backup copy cannot
|
||||
be read. The recovery kit under Admin, Encryption lists every key a restore of
|
||||
this server needs:
|
||||
|
||||
| Platform | Where the key is set |
|
||||
| --------- | --------------------------------------------------------------- |
|
||||
| Compose | `.env` next to `docker-compose.yml` |
|
||||
| TrueNAS | Apps, HealthLog, Edit, Environment |
|
||||
| Coolify | the application's Environment Variables |
|
||||
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
|
||||
| Portainer | the stack's Environment variables |
|
||||
- `ENCRYPTION_KEY` (or each id in the `ENCRYPTION_KEYS` map), including a
|
||||
retired id for as long as a stored or off-host backup copy still needs it;
|
||||
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
|
||||
off-host copies are set up;
|
||||
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
|
||||
(no data is lost).
|
||||
|
||||
Each key is shown by id and a fingerprint (the first 12 hex characters of
|
||||
SHA-256 over the key), never the key itself, and **Check my copy** compares a
|
||||
pasted copy without storing it. Keep a copy of each outside the server, then
|
||||
confirm the kit. Admins see a dashboard reminder until it is confirmed, and the
|
||||
kit is due again whenever a key it needs changes: a rotation, a new backup
|
||||
key, a re-keyed install. The kit also links the
|
||||
[disaster recovery guide](../ops/disaster-recovery.md).
|
||||
|
||||
The **Recovery readiness** card on the admin overview and the Backups page
|
||||
judges every backup signal in one place: the database dump, the weekly account
|
||||
copies, the off-host copies, the last restore drill, whether off-host copies
|
||||
are set up, whether the bucket ever removes old database dumps, and the
|
||||
recovery kit. An hourly check sends each admin a
|
||||
**Backup health** notification when a signal turns to "needs attention" or "at
|
||||
risk", once a day while it stays at risk, and once when it recovers. Route or
|
||||
switch it off per channel under Notifications.
|
||||
|
||||
| Platform | Where the key is set |
|
||||
| --------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Compose | `.env` next to `docker-compose.yml` |
|
||||
| TrueNAS | Apps, HealthLog, Edit: the Encryption Key field (`ENCRYPTION_KEY` in a YAML install's environment) |
|
||||
| Coolify | the application's Environment Variables |
|
||||
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
|
||||
| Portainer | the stack's Environment variables |
|
||||
|
||||
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
|
||||
`portainer`) to open the step on your platform's instructions.
|
||||
`portainer`) to open the kit on your platform's instructions.
|
||||
|
||||
To compute the fingerprint of your copy:
|
||||
To compute the fingerprint of your copy of a hex key, and of the token key:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
printf %s "<API_TOKEN_HMAC_KEY>" | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
**HealthLog refuses to serve: encryption key does not match this database.**
|
||||
@@ -341,6 +369,15 @@ written under that key, so remove the setting once the key is confirmed.
|
||||
Your database is the only stateful piece — back it up, and back up the
|
||||
key that decrypts it.
|
||||
|
||||
**Database dumps the app takes itself.** Every night, and before an update
|
||||
applies migrations, the app writes a `pg_dump` of the whole database with a
|
||||
manifest into the `backups` volume and keeps the newest few. With the
|
||||
off-host bucket configured, each nightly dump is also uploaded, encrypted.
|
||||
`docker compose run --rm db-restore latest` restores one into an empty
|
||||
database. What is kept where, the keys a restore needs, and restoring onto a
|
||||
new server: [`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md).
|
||||
Keep a copy of those dumps off this host.
|
||||
|
||||
**Local snapshot (the common single-box case).** Dump the `db` service
|
||||
with `pg_dump`:
|
||||
|
||||
@@ -374,8 +411,11 @@ encryption key safely (keep the old key until zero rows remain on it) see
|
||||
A new image can bring database migrations, and `docker-entrypoint.sh`
|
||||
applies them on the first start of that image. Most are additive, but some
|
||||
rewrite data, and going back to the previous image does not undo them. The
|
||||
only way back from a migration that went wrong is the dump you took before
|
||||
it. So take one before every pull:
|
||||
way back from a migration that went wrong is a dump taken before it. The
|
||||
entrypoint takes one itself (`healthlog-<time>-pre-migration.dump` in the
|
||||
`backups` volume, see
|
||||
[`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md)); a dump of
|
||||
your own before every pull, kept off the host, is the second copy:
|
||||
|
||||
```bash
|
||||
# 1. Dump the database (same command as under "Backup and restore").
|
||||
@@ -393,17 +433,27 @@ curl -s http://localhost:3000/api/version
|
||||
Keep the dump until the new version has run for a few days, and keep your
|
||||
encryption key with it: a dump without the key cannot be read.
|
||||
|
||||
The database image is a separate matter: a new Postgres minor is a change of
|
||||
the `image:` line. The move from 16 to 18 runs by itself on the first start
|
||||
of the compose file from v1.46; a database you run yourself moves by dump
|
||||
and restore. Both are in
|
||||
[`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md).
|
||||
|
||||
The start log says what is about to happen before the migrations run:
|
||||
|
||||
```text
|
||||
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
|
||||
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
|
||||
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
|
||||
HealthLog: pre-migration dump written: healthlog-20261025T081502Z-pre-migration.dump (212.4 MB, 231 tables, 38 s).
|
||||
HealthLog: Running database migrations...
|
||||
```
|
||||
|
||||
or `HealthLog: Database schema is up to date.` when the image brings none.
|
||||
The line is a notice and does not pause the start: the migrations run right
|
||||
after it. So the dump belongs before step 2, not after you see the line; by
|
||||
then the database is already being migrated.
|
||||
The migrations run right after the dump. A dump that fails on a writable
|
||||
directory stops the start before anything is migrated, and the log names the
|
||||
switch that skips it once (`DB_BACKUP_BEFORE_MIGRATE=false`). Your own dump
|
||||
belongs before step 2, not after you see these lines; by then the database is
|
||||
already being migrated.
|
||||
|
||||
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
|
||||
nothing was changed; the recovery is in
|
||||
|
||||
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
|
||||
|
||||
> **Read this before enabling writes.** A remote, write-capable connector
|
||||
> reached over OAuth is a powerful combination. Read the
|
||||
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
|
||||
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
|
||||
> of this page first.
|
||||
|
||||
## Prerequisites
|
||||
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
|
||||
The switch lives with every other module: **Settings → Modules**, the
|
||||
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
|
||||
|
||||
**Settings → MCP** is where you use it. The page has three parts:
|
||||
**Settings → MCP connector** is where you use it. The page has three parts:
|
||||
|
||||
- **State** — whether the module is on, with a link to its switch in
|
||||
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
|
||||
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
|
||||
|
||||
## 2. Mint a connector token
|
||||
|
||||
Still in **Settings → MCP → Tokens**:
|
||||
Still in **Settings → MCP connector → Tokens**:
|
||||
|
||||
1. Choose the scope with the **write** toggle:
|
||||
- **off** → a read-only token (`health:read`). This is the default and
|
||||
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
|
||||
client-id-metadata-document clients with dynamic registration as a
|
||||
fallback.
|
||||
3. Approve the consent screen. The connection then appears under
|
||||
**Settings → MCP → Connections**, where you can revoke it.
|
||||
**Settings → MCP connector → Connections**, where you can revoke it.
|
||||
|
||||
No token to paste — OAuth mints and rotates a short-lived `health:read`
|
||||
access token (60 minutes, with a refresh token) behind the scenes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HealthLog on Portainer
|
||||
|
||||
HealthLog ships a Portainer **app template** that deploys the app and its
|
||||
PostgreSQL 16 database as one stack from the project `docker-compose.yml`.
|
||||
PostgreSQL 18 database as one stack from the project `docker-compose.yml`.
|
||||
|
||||
## Add the template
|
||||
|
||||
@@ -39,7 +39,7 @@ curl -s http://<host>:3000/api/version
|
||||
|
||||
## Notes
|
||||
|
||||
- Persistent state lives in one named volume (`pgdata`, the Postgres data dir);
|
||||
- Persistent state lives in one named volume (`postgres-data`, the Postgres data);
|
||||
the app container is stateless. Back up the database to back up everything.
|
||||
- Pin a release tag (`HEALTHLOG_IMAGE_REF=:vX.Y.Z`) for deliberate upgrades, or
|
||||
track `:latest`. Skim the
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": 3,
|
||||
"title": "HealthLog",
|
||||
"name": "healthlog",
|
||||
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
|
||||
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
|
||||
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
|
||||
"categories": ["Tools", "Other"],
|
||||
"platform": "linux",
|
||||
@@ -33,7 +33,7 @@
|
||||
{
|
||||
"name": "HEALTHLOG_PLATFORM",
|
||||
"label": "Platform hint",
|
||||
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
|
||||
"description": "Opens the admin's recovery kit on the Portainer instructions.",
|
||||
"default": "portainer"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -95,19 +95,31 @@ different things depending on which button you press.
|
||||
two actions below, the row stays. A start that was folded into a later
|
||||
cycle stays for as long as that cycle does, because deleting the later
|
||||
cycle gives it back.
|
||||
- **Settings → Delete All Data** removes the rows outright, tombstones
|
||||
- **Settings → Advanced → Delete all data** removes the rows, tombstones
|
||||
included. Everything the account owns goes except what you sign in
|
||||
with; the confirmation dialog lists it.
|
||||
- **Deleting the account** removes the account row, and the database
|
||||
cascades every record with it.
|
||||
with; the confirmation dialog lists it. Before it does, it keeps a copy
|
||||
of the account on the server for the deletion grace window (14 days by
|
||||
default), so a mistaken wipe can be restored; the copy is erased when
|
||||
the window ends.
|
||||
- **Deleting the account** locks it at once and erases it, with every
|
||||
record, when the grace window ends. Until then an admin can undo it.
|
||||
|
||||
Two consequences follow, and they matter for the seizure threat model
|
||||
`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the
|
||||
copy and without an undo.
|
||||
|
||||
Three consequences follow, and they matter for the seizure threat model
|
||||
above. A host imaged while a per-entry deletion is the only deletion you
|
||||
have performed still holds that entry. And a backup taken before any of
|
||||
these actions still holds whatever it captured, because a backup is a
|
||||
copy of a moment. If your reason for deleting is that the data must not
|
||||
be recoverable from the machine, use one of the two whole-record actions
|
||||
and rotate your backups.
|
||||
have performed still holds that entry; so does a host imaged inside the
|
||||
grace window. A backup taken before any of these actions still holds
|
||||
whatever it captured, because a backup is a copy of a moment: the nightly
|
||||
database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP`
|
||||
nights on the server, and your bucket's lifecycle rule for `database/`).
|
||||
And after the move to Postgres 18 the old `pgdata` volume is a full copy
|
||||
of the database from that day until you remove it. If your reason for
|
||||
deleting is that the data must not be recoverable from the machine, set
|
||||
the grace window to 0 or wait it out, remove the old volume, and let the
|
||||
dumps age out or delete them
|
||||
([backup-restore.md](../ops/backup-restore.md)).
|
||||
|
||||
## Practical hardening
|
||||
|
||||
|
||||
@@ -16,17 +16,19 @@ tailnet with no extra container. See
|
||||
|
||||
## Pre-flight — env vars the proxy interacts with
|
||||
|
||||
| Variable | Required for | Notes |
|
||||
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
| Variable | Required for | Notes |
|
||||
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
|
||||
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
|
||||
|
||||
Set the URL pair to the public origin (`https://your-instance.example.com`)
|
||||
and restart the `app` container. The image reads both at startup; no
|
||||
rebuild needed.
|
||||
|
||||
All three variables are on the shipped `docker-compose.yml`
|
||||
All five variables are on the shipped `docker-compose.yml`
|
||||
`environment:` whitelist, so a value in `.env` reaches the container
|
||||
without editing the compose file. (Before v1.37.33 the whitelist was
|
||||
missing `TRUST_PROXY_HOPS`; on those versions add
|
||||
@@ -40,22 +42,105 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
|
||||
This guards against a client rotating `X-Forwarded-For` per request
|
||||
to defeat IP-based rate limits.
|
||||
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| --------------------------------------------------------- | ------------------------------------- |
|
||||
| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| --------------------------------------------------------- | ---------------------------------- |
|
||||
| App is internet-facing with no proxy | `0` (the connection's own address) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
|
||||
A misconfigured count logs a one-shot warning to stderr and collapses
|
||||
every anonymous caller into one shared rate-limit bucket. Match the
|
||||
value to the actual hop count.
|
||||
|
||||
Forwarding headers are believed only from a connection whose own address
|
||||
is not a public one, which is where a proxy you run connects from:
|
||||
|
||||
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
|
||||
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
|
||||
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
|
||||
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
|
||||
`224.0.0.0` up (multicast and reserved);
|
||||
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
|
||||
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
|
||||
IPv4-mapped or embedded address from one of the IPv4 ranges above.
|
||||
|
||||
The reserved and multicast blocks are in the list because no host on the
|
||||
internet can open a connection from them, so a peer there sits on a network
|
||||
you run. A request that reaches HealthLog straight from a public
|
||||
address carries headers its sender wrote, so HealthLog ignores them and
|
||||
uses the connection's own address, and says so once in the log. The log
|
||||
line also shows up when the published port is open to the internet beside
|
||||
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
|
||||
|
||||
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
|
||||
only as good as the network in front of the port. Read the next section
|
||||
before relying on it.
|
||||
|
||||
### `TRUSTED_PROXY_CIDRS` — name the proxy
|
||||
|
||||
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
|
||||
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
|
||||
single host). Once it is set, only a connection from inside those ranges
|
||||
may report the client's address. Every other connection is its own client,
|
||||
whatever its address, private ones included, and the log says so once.
|
||||
|
||||
Why it matters: some setups make every caller look like a private address.
|
||||
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
|
||||
behind a published port such as `3000:3000`) forward each connection from
|
||||
the gateway address of the Docker network, which is private. On those
|
||||
hosts, someone who can reach the published port directly, from the LAN or
|
||||
from the internet if the port is open, passes the private-address check
|
||||
and can again choose the address that every per-IP rate limit, the audit
|
||||
log and the sign-in notices see, a new one per request if they like.
|
||||
Naming your proxy closes that. The default port binding in
|
||||
`docker-compose.yml` stays as it is (LAN installs rely on it); this
|
||||
variable is the fix.
|
||||
|
||||
What to set:
|
||||
|
||||
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
|
||||
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
|
||||
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
|
||||
| No proxy, or `tailscale serve` only | leave it unset |
|
||||
|
||||
At boot the app logs one `[proxy-trust]` line naming the rule in force:
|
||||
which connections it reads forwarding headers from and how many hops deep.
|
||||
A value that is not a CIDR is logged as an error, and until it is fixed no
|
||||
connection may report a client address.
|
||||
|
||||
**A proxy that connects from a public address.** Without
|
||||
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
|
||||
it resolves to the proxy's own address, so all your users share one
|
||||
rate-limit bucket and one person mistyping a password can hold sign-in back
|
||||
for everyone. Releases before v1.46 believed any peer, so this shows up after
|
||||
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
|
||||
on a private network with HealthLog) and restart.
|
||||
|
||||
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
|
||||
|
||||
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
|
||||
the `CF-Connecting-IP` header, before any of the checks above: from every
|
||||
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
|
||||
Cloudflare sets that header on every request through its edge, but any
|
||||
client can send it too. So the flag is safe only when nothing but
|
||||
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
|
||||
port, or a firewall that admits only
|
||||
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
|
||||
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
|
||||
better still). If the origin also answers on its own public address, a
|
||||
caller who finds it chooses the address every per-IP rate limit, the audit
|
||||
log and the sign-in locations record, and changes it on every request.
|
||||
Without Cloudflare in front, leave the flag unset.
|
||||
|
||||
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
|
||||
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
|
||||
`X-Forwarded-For`, which covers a single proxy that sets only
|
||||
`X-Real-IP`. With `0` neither header is believed, because without a proxy
|
||||
in front both come from the caller. With `2` or more, the hop count
|
||||
in front both come from the caller; the address the connection came from
|
||||
is used instead. With `2` or more, the hop count
|
||||
describes an `X-Forwarded-For` chain, so have your proxies send one.
|
||||
|
||||
If requests arrive with a forwarding header the setting does not let
|
||||
@@ -242,6 +327,15 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
|
||||
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
|
||||
env var down to the actual chain length and restart.
|
||||
|
||||
If every request logs the same client address, the proxy's own, the proxy
|
||||
is not one HealthLog believes: the `[getClientIp]` warning about a
|
||||
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
|
||||
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
|
||||
address at all (requests keep working) and is named in the `[proxy-trust]`
|
||||
boot line.
|
||||
|
||||
## Running over plain HTTP on a LAN (no reverse proxy)
|
||||
|
||||
The session cookie carries the `Secure` flag by default under
|
||||
|
||||
@@ -174,7 +174,7 @@ sized for the web traffic alone leaves the worker's jobs queueing for
|
||||
the same connections.
|
||||
|
||||
Plan total Postgres slots as **container_count × budget**.
|
||||
A stock Postgres 16 container ships with `max_connections = 100`, so
|
||||
A stock Postgres container ships with `max_connections = 100`, so
|
||||
the safe envelope is:
|
||||
|
||||
| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 |
|
||||
|
||||
@@ -101,5 +101,6 @@ and host names before it is shown.
|
||||
|
||||
`docker compose logs app` prints every wide event as one JSON line;
|
||||
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
|
||||
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
|
||||
go to Loki, and GlitchTip (Admin, Integrations, once a DSN is set there)
|
||||
receives unexpected server errors.
|
||||
Each line carries `request_id`, so an error id finds its line there too.
|
||||
+187
-60
@@ -1,82 +1,167 @@
|
||||
# HealthLog on TrueNAS SCALE
|
||||
# HealthLog on TrueNAS
|
||||
|
||||
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
|
||||
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
|
||||
is stateless; everything you care about lives in the database dataset and in
|
||||
one value you must keep yourself: the encryption key.
|
||||
HealthLog runs on TrueNAS Community Edition (formerly SCALE) as a Docker app:
|
||||
one container from `ghcr.io/mbombeck/healthlog` and one PostgreSQL database.
|
||||
The app container is stateless. Everything you care about lives in the
|
||||
database dataset and in one value you must keep yourself: the encryption key.
|
||||
|
||||
## What the app needs
|
||||
> **Status: pending catalog review.** A HealthLog app for the TrueNAS
|
||||
> Community train has been prepared but is not listed yet. Until it is, use
|
||||
> [Install via YAML](#before-the-listing-install-via-yaml) at the end of this
|
||||
> page. This note goes away once the app appears under Apps, Discover Apps.
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
## Install from the Community train
|
||||
|
||||
## Environment
|
||||
1. Generate two secrets. Open the TrueNAS shell (System, Shell) and run the
|
||||
command twice, once for each field:
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
|
||||
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
|
||||
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
|
||||
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs.
|
||||
Each run prints 64 hex characters. Put both in your password manager
|
||||
before you continue; see [Save the recovery kit](#save-the-recovery-kit).
|
||||
|
||||
2. Open Apps, Discover Apps, search for **HealthLog** and choose Install.
|
||||
3. Fill in the form:
|
||||
|
||||
| Field | What to enter |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Timezone | your timezone |
|
||||
| Database Password | any strong password; only the two containers use it |
|
||||
| Encryption Key | the first `openssl rand -hex 32` output |
|
||||
| Token Signing Key | the second output. Changing it later signs everyone out |
|
||||
| App URL | the address you open HealthLog at, with scheme and port, for example `http://192.168.1.10:30536` |
|
||||
| Secure Cookies | leave off when you open HealthLog over `http://` on the LAN; turn on behind an HTTPS reverse proxy (see below) |
|
||||
| WebUI Port | `30536` unless that port is taken |
|
||||
| Postgres Data Storage | the default ixVolume, or a host path on a dataset you snapshot |
|
||||
| Resources | keep memory at 1536 MB or more; the default of 4096 MB is fine |
|
||||
|
||||
4. Install. The first start applies the database migrations before the web
|
||||
server listens, so give it a minute. The app shows as running once
|
||||
`/api/health` answers.
|
||||
5. Open the App URL. The first account you register becomes the admin.
|
||||
|
||||
The App URL has to match the address in the browser: passkeys, sign-in
|
||||
redirects and the links in notifications are built from it. If you later put
|
||||
HealthLog behind a reverse proxy with its own domain, change App URL to that
|
||||
`https://` address.
|
||||
|
||||
Settings the form does not show go under Additional Environment Variables, for
|
||||
example `UPDATE_CHECK_DISABLED=1`. The full list is in
|
||||
[`.env.production.example`](../../.env.production.example) and on the
|
||||
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
|
||||
To use your own GeoLite2 databases, add the directory under Additional Storage
|
||||
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
|
||||
|
||||
HealthLog writes a whole-database dump every night, and one before each update
|
||||
that brings migrations, into `/var/lib/healthlog/backups` inside the app
|
||||
container. Give that path a dataset of its own (a host path or an ixVolume,
|
||||
writable by uid `1001`), under Additional Storage if the form has no field for
|
||||
it. Without one the directory sits inside the container, which an update
|
||||
replaces, so HealthLog keeps no dump there: the start log, the admin backups
|
||||
page and the recovery readiness card say so, and with an off-host bucket the
|
||||
nightly dump goes to the bucket only.
|
||||
|
||||
## Plain HTTP on the LAN
|
||||
|
||||
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
|
||||
for that. If it is left at its default, the browser drops the sign-in cookie
|
||||
and the login page says so before you type a password: "This server only sets
|
||||
its sign-in cookie over https://".
|
||||
Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for
|
||||
that. With it on, the browser drops the sign-in cookie on a plain-HTTP page,
|
||||
and the login page says so before you type a password: "Signing in cannot
|
||||
complete on this page". Turn it on only when HealthLog is served over
|
||||
`https://`, and set App URL to that `https://` address.
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with
|
||||
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
|
||||
and reinstalled with a new key over the old database dataset, the data is
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with the
|
||||
Encryption Key. The key is not stored in the database. If the app is deleted
|
||||
and installed again with a new key over the old database dataset, the data is
|
||||
still there but cannot be read.
|
||||
|
||||
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
|
||||
Environment) into your password manager.
|
||||
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
|
||||
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
|
||||
Compare it with your copy:
|
||||
TrueNAS keeps the values you typed into the form with the app's configuration
|
||||
on the apps pool. Treat that as a convenience, not as your copy: deleting the
|
||||
app removes it, and a reinstall starts with empty fields. Keep the key in a
|
||||
password manager or another place outside the NAS.
|
||||
|
||||
1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key
|
||||
field under HealthLog Configuration.
|
||||
2. Sign in as the admin and open Admin, Encryption. The **Recovery kit** lists
|
||||
every key a restore needs (the Encryption Key, the token key and, with
|
||||
off-host copies, the backup key), each with an id and a fingerprint, the
|
||||
first 12 hex characters of SHA-256 over the key. Compare it with your copy:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
or paste the copy into "Check my copy" (it is compared and dropped, never
|
||||
stored or logged).
|
||||
or paste the copy into **Check my copy**. It is compared and dropped, never
|
||||
stored or logged.
|
||||
|
||||
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
|
||||
After a key rotation the step comes back for the new key.
|
||||
3. Press **I have a copy of every key**. Until you do, admins see a reminder on
|
||||
the dashboard. After a key rotation or a new backup key the kit is due again.
|
||||
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the kit opens on these
|
||||
instructions.
|
||||
|
||||
## Backups and restore
|
||||
|
||||
A restore needs two things: a backup of the database **and** the Encryption
|
||||
Key that was active when it was taken. One without the other gets you nothing
|
||||
back.
|
||||
|
||||
- **Snapshots.** Snapshot the Postgres Data Storage dataset (the ixVolume
|
||||
lives under the apps pool, a host path wherever you put it). Stop the app
|
||||
first, or the snapshot holds a database that was mid-write.
|
||||
- **A dump.** Open a shell on the `postgres` container (Apps, HealthLog,
|
||||
the container's Shell) and run:
|
||||
|
||||
```bash
|
||||
pg_dump -U healthlog healthlog > /var/lib/postgresql/healthlog-$(date +%F).sql
|
||||
```
|
||||
|
||||
The file lands in the Postgres Data Storage dataset, next to the database
|
||||
files, so copy it off the NAS as well.
|
||||
|
||||
- **The nightly dump.** With a dataset on `/var/lib/healthlog/backups` (see
|
||||
above), the newest seven nightly dumps and the last two taken before a
|
||||
migration stay there; [disaster-recovery.md](../ops/disaster-recovery.md)
|
||||
restores one onto a new server.
|
||||
- **Off-host.** HealthLog can push encrypted nightly backups to any
|
||||
S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md).
|
||||
|
||||
To restore a dump, run the app with the Encryption Key the dump was taken
|
||||
under, put the dump file in the Postgres Data Storage dataset, and from the
|
||||
`postgres` container shell replace the database with it:
|
||||
|
||||
```bash
|
||||
psql -U healthlog -d postgres \
|
||||
-c "DROP DATABASE healthlog WITH (FORCE);" \
|
||||
-c "CREATE DATABASE healthlog OWNER healthlog;"
|
||||
psql -v ON_ERROR_STOP=1 -U healthlog healthlog < /var/lib/postgresql/healthlog-YYYY-MM-DD.sql
|
||||
```
|
||||
|
||||
Then restart the app. Restoring over the existing database without dropping
|
||||
it first does not work: the tables the background queue creates on its own
|
||||
refuse a plain overwrite.
|
||||
|
||||
## When the key does not match the database
|
||||
|
||||
At every start HealthLog checks that its key opens the data already in the
|
||||
database. If it does not, it refuses to serve instead of failing on every
|
||||
encrypted value: pages show "HealthLog cannot open its data", API requests
|
||||
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
|
||||
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
|
||||
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
|
||||
stays visible.
|
||||
database. A missing or malformed key stops the container at once with
|
||||
`HealthLog: ERROR — encryption configuration is invalid.` in its log. A
|
||||
well-formed key that does not open the stored data is different: HealthLog
|
||||
refuses to serve instead of failing on every encrypted value. Pages show
|
||||
"HealthLog cannot open its data", API requests answer `503` with
|
||||
`meta.errorCode` `encryption.key_mismatch`, `/api/health` answers `503` with
|
||||
`reason: "encryption_key_mismatch"`, and no background jobs run. TrueNAS
|
||||
shows the app as unhealthy rather than crashed, so the reason stays visible in
|
||||
the app's logs.
|
||||
|
||||
Fix it one of three ways, then restart the app:
|
||||
|
||||
1. Put the original `ENCRYPTION_KEY` back.
|
||||
2. Point `DATABASE_URL` at the database that belongs to this key.
|
||||
1. Put the original Encryption Key back (Apps, HealthLog, Edit).
|
||||
2. Point the app at the database that belongs to this key, for example by
|
||||
restoring that dataset.
|
||||
3. Start fresh with an empty database. The old data and its backups need the
|
||||
old key.
|
||||
|
||||
@@ -91,9 +176,8 @@ checks again at the next start.
|
||||
|
||||
If you are certain the configured key is the one the data was written with,
|
||||
and the app still refuses because the check recorded a different key earlier,
|
||||
remove that record and restart. Open a shell on the database container (Apps,
|
||||
HealthLog, the database container's Shell) and run, with the key id the log
|
||||
names (`v1` unless you use `ENCRYPTION_KEYS`):
|
||||
remove that record and restart. Open a shell on the `postgres` container and
|
||||
run, with the key id the log names (`v1` unless you use `ENCRYPTION_KEYS`):
|
||||
|
||||
```bash
|
||||
psql -U healthlog healthlog \
|
||||
@@ -108,7 +192,7 @@ id: the rotation script removes its record once no values remain under it.
|
||||
|
||||
On a first start there is no record to remove. If the check refuses there and
|
||||
you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under
|
||||
Apps, HealthLog, Edit, Environment as a last resort. The check still runs and
|
||||
Additional Environment Variables as a last resort. The check still runs and
|
||||
logs, `/api/health` reports `warning: "encryption_key_mismatch"` without
|
||||
failing, the app serves, and no key is recorded until the check passes. This
|
||||
switches off a safety check: with a wrong key every encrypted value fails and
|
||||
@@ -117,6 +201,49 @@ confirmed.
|
||||
|
||||
## Updates
|
||||
|
||||
Pick the new tag, take a snapshot of the database dataset first, and update.
|
||||
Migrations run on start; the first start of a new install applies all of them,
|
||||
which is why the start period above is generous.
|
||||
The catalog follows HealthLog releases on its own: when a new image is
|
||||
published, the TrueNAS catalog picks it up and the app shows an update. Take a
|
||||
snapshot or a dump first, then update.
|
||||
|
||||
Database migrations run at every start, before the web server listens, and
|
||||
only go forward. The start log says how many are pending. Going back to an
|
||||
older version with Roll Back does not undo them, and an older version may not
|
||||
start against a database a newer one has migrated. The way back is the
|
||||
database backup you took before the update, restored together with the older
|
||||
version.
|
||||
|
||||
The Postgres Image field offers the Postgres major versions the catalog
|
||||
supports. Changing it runs a one-way upgrade of the database files, so back up
|
||||
before you touch it.
|
||||
|
||||
## Before the listing: install via YAML
|
||||
|
||||
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
|
||||
the menu next to Custom App, Install via YAML, using these values:
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
|
||||
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
|
||||
| Volumes | `/var/lib/healthlog/backups` for the database dumps (uid `1001`); the database its own |
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
|
||||
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS. |
|
||||
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's recovery kit opens on the TrueNAS instructions |
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs. In a YAML install the key sits in the
|
||||
environment block, so the place to copy it from is `ENCRYPTION_KEY` there.
|
||||
To move from a YAML install to the catalog app later, take a dump, install the
|
||||
catalog app with the same key, and restore the dump as described under
|
||||
[Backups and restore](#backups-and-restore).
|
||||
+33
-18
@@ -6,10 +6,13 @@ self-contained install (app + database as one unit) and is recommended. The
|
||||
Unraid UI, but because CA templates describe a single container you install
|
||||
PostgreSQL separately and wire the two together.
|
||||
|
||||
HealthLog stores all persistent state in PostgreSQL — the app container itself is
|
||||
stateless (the offline geo-IP databases are either baked in at build time or
|
||||
mounted read-only; replaceable either way, not state). A backup of
|
||||
the database is a backup of everything.
|
||||
HealthLog stores all persistent state in PostgreSQL. The app container keeps one
|
||||
more thing, its database dumps: every night, and before each update that brings
|
||||
migrations, it writes a whole-database dump to `/var/lib/healthlog/backups`
|
||||
(the offline geo-IP databases are either baked in at build time or mounted
|
||||
read-only; replaceable either way, not state). A backup of the database is a
|
||||
backup of everything, and those dumps are that backup, so that path needs a
|
||||
share of its own.
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -69,10 +72,12 @@ curl -s http://tower:3000/api/version
|
||||
Use this if you prefer the per-app Unraid UI. You install PostgreSQL yourself,
|
||||
then add HealthLog pointed at it.
|
||||
|
||||
### Step 1 — install PostgreSQL 16
|
||||
### Step 1 — install PostgreSQL
|
||||
|
||||
1. In **Apps** (Community Applications), search for a **PostgreSQL** container
|
||||
(for example jj9987's `postgresql`). Install version 16.
|
||||
(for example jj9987's `postgresql`). Install version 18; an existing 16
|
||||
works too. Moving a 16 to 18 later is a dump and restore, see
|
||||
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md#by-hand).
|
||||
2. Set:
|
||||
- `POSTGRES_USER` = `healthlog`
|
||||
- `POSTGRES_PASSWORD` = your generated password
|
||||
@@ -101,12 +106,20 @@ Fill in the fields:
|
||||
| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access |
|
||||
| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) |
|
||||
| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` |
|
||||
| Database dumps | `/mnt/user/appdata/healthlog/backups` (the default) |
|
||||
|
||||
Replace `<password>` and `<postgres-host>` with the values from Step 1. Apply,
|
||||
then open `http://tower:3000` — the first registered user becomes the admin.
|
||||
Replace `<password>` and `<postgres-host>` with the values from Step 1. The
|
||||
dumps folder must be writable by the app's user, uid `1001`; create it once
|
||||
from the terminal with
|
||||
`mkdir -p /mnt/user/appdata/healthlog/backups && chown 1001:1001 /mnt/user/appdata/healthlog/backups`.
|
||||
Apply, then open `http://tower:3000` — the first registered user becomes the
|
||||
admin.
|
||||
|
||||
The HealthLog container has no persistent volume of its own; all state is in the
|
||||
PostgreSQL container you installed in Step 1. Back that one up.
|
||||
All account data is in the PostgreSQL container you installed in Step 1, and
|
||||
the nightly dumps of it land in the dumps folder. Without that path the dumps
|
||||
would sit inside the HealthLog container, which every update replaces, so
|
||||
HealthLog keeps none: the start log, the admin backups page and the recovery
|
||||
readiness card say so. Back up both.
|
||||
|
||||
---
|
||||
|
||||
@@ -114,20 +127,22 @@ PostgreSQL container you installed in Step 1. Back that one up.
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
|
||||
(`http://tower:3000`). Left at its default, the session cookie carries the
|
||||
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
|
||||
"do nothing" — the page reloads to the login screen with no error.
|
||||
`Secure` flag, which the browser would drop over HTTP. The login page says so
|
||||
before anything is sent: "Signing in cannot complete on this page".
|
||||
|
||||
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
|
||||
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
|
||||
and `APP_URL` at the public `https://` URL.
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
Copy `ENCRYPTION_KEY` from the template into your password manager, then
|
||||
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
|
||||
opens on the Unraid instructions). A reinstall with a new key over the old
|
||||
database makes the data unreadable; HealthLog then refuses to serve and says
|
||||
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
|
||||
Copy `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY` and, with off-host copies,
|
||||
`BACKUP_ENCRYPTION_KEY` from the template into your password manager, then
|
||||
confirm the recovery kit under Admin, Encryption (set
|
||||
`HEALTHLOG_PLATFORM=unraid` so the kit opens on the Unraid instructions). A
|
||||
reinstall with a new key over the old database makes the data unreadable;
|
||||
HealthLog then refuses to serve and says so. See
|
||||
[Getting started](getting-started.md#save-the-recovery-kit).
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!--
|
||||
HealthLog — Unraid Community Applications template.
|
||||
|
||||
CA templates describe ONE container. HealthLog needs PostgreSQL 16, so
|
||||
CA templates describe ONE container. HealthLog needs PostgreSQL (18, or 16), so
|
||||
install a PostgreSQL container FIRST (CA has community PostgreSQL templates),
|
||||
create a `healthlog` database + user, then point DATABASE_URL below at it.
|
||||
Full walkthrough: docs/self-hosting/unraid.md.
|
||||
@@ -26,7 +26,7 @@
|
||||
cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no
|
||||
vendor cloud, no telemetry.
|
||||
|
||||
REQUIRES a separate PostgreSQL 16 container — install one first (CA has
|
||||
REQUIRES a separate PostgreSQL 18 (or 16) container — install one first (CA has
|
||||
community PostgreSQL templates), create a database and user, then set
|
||||
DATABASE_URL below. Step-by-step guide:
|
||||
https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/unraid.md
|
||||
@@ -46,14 +46,14 @@
|
||||
|
||||
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||
|
||||
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
|
||||
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
|
||||
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
|
||||
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
|
||||
|
||||
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
|
||||
@@ -63,4 +63,6 @@
|
||||
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {"v1":"<hex>","v2":"<hex>"}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
|
||||
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
|
||||
<Config Name="Database dumps" Target="/var/lib/healthlog/backups" Default="/mnt/user/appdata/healthlog/backups" Mode="rw" Description="Where HealthLog keeps its nightly database dump and the one it takes before each update that brings migrations. Without this path the dumps would sit inside the container, which an update replaces, so none is kept. Must be writable by uid 1001 (chown 1001:1001 the folder once)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/healthlog/backups</Config>
|
||||
</Container>
|
||||
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
|
||||
at the chart's width directly below. **Never** wrap to a second row.
|
||||
|
||||
If the user enables more tiles than fit, the dashboard surfaces a banner
|
||||
inviting them to deactivate or reorder via Settings → Dashboard. The
|
||||
inviting them to deactivate or reorder via Settings → Arrangement. The
|
||||
layout editor in Settings shows a live preview of the constraint and
|
||||
flags overflow before save.
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
|
||||
* the snapshot is gone — which is what "the restore replaces the
|
||||
* account's data tables" in `docs/ops/backup-restore.md` means when it is
|
||||
* spelled out.
|
||||
* spelled out;
|
||||
* 6. a second person downloads their own full backup, their account is
|
||||
* recreated empty under the same id (the runbook's new host), the admin
|
||||
* uploads the file and restores it, and the person signs in to the same
|
||||
* rows under the same ids.
|
||||
*
|
||||
* Refusing controls, because a restore path that cannot say no is worse than
|
||||
* none: a stored copy with one flipped ciphertext byte is refused and changes
|
||||
@@ -36,19 +40,20 @@
|
||||
*
|
||||
* Everything the journey does, it does through the app: the readings and the
|
||||
* document ride the page's own `fetch`, the archive and the snapshot ride the
|
||||
* real buttons, the restore rides the typed-confirmation dialog. The only two
|
||||
* real buttons, the restore rides the typed-confirmation dialog. The only
|
||||
* things reaching Postgres directly are the ones a browser cannot express —
|
||||
* resetting the account between repetitions, and corrupting one stored copy so
|
||||
* the authenticated envelope has something to refuse. Both live in
|
||||
* `setup/backup-journey-fixture.ts`.
|
||||
* resetting the accounts between repetitions, corrupting one stored copy so
|
||||
* the authenticated envelope has something to refuse (both in
|
||||
* `setup/backup-journey-fixture.ts`), and recreating the second account under
|
||||
* its own id (`setup/critical-fixture.ts`).
|
||||
*
|
||||
* Serial and single-project: the four tests are four acts of one journey, they
|
||||
* mutate the account destructively, and the last one moves a session's record
|
||||
* Serial and single-project: the five tests are five acts of one journey, they
|
||||
* mutate the accounts destructively, and the last one moves a session's record
|
||||
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Page, Response } from "@playwright/test";
|
||||
|
||||
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
|
||||
|
||||
@@ -65,6 +70,13 @@ import {
|
||||
storedMeasurements,
|
||||
storeTamperedCopy,
|
||||
} from "./setup/backup-journey-fixture";
|
||||
import {
|
||||
accountRowCounts,
|
||||
clearLoginBuckets,
|
||||
criticalAccount,
|
||||
recreateCriticalAccount,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
import {
|
||||
actWithReproof,
|
||||
completeReproofWithPassword,
|
||||
@@ -79,8 +91,20 @@ test.describe.configure({ mode: "serial" });
|
||||
* The pass backs up every account in the database, the restore rewrites a
|
||||
* dozen tables in one transaction, and the archive runs Argon2id at the OWASP
|
||||
* cost. None of that fits the suite's 30-second default on a loaded runner.
|
||||
* On top of that, a test must hold the restore wait below in full.
|
||||
*/
|
||||
test.setTimeout(180_000);
|
||||
test.setTimeout(240_000);
|
||||
|
||||
/**
|
||||
* How long a queued restore may take to finish. The restore shares the heavy
|
||||
* lane with the backup pass, and a restore that finds the lane taken goes
|
||||
* back to the queue for `LANE_DEFER_SECONDS` (60 s, `src/lib/jobs/heavy-lane.ts`).
|
||||
* The pass this journey starts can still be finishing when the restore is
|
||||
* asked for: on a loaded machine its tail ran 15 s, the restore was put back
|
||||
* by 60, and a 60-second wait ran out one second before it started. The
|
||||
* budget holds one deferral and a second yield with room to run.
|
||||
*/
|
||||
const RESTORE_BUDGET_MS = 135_000;
|
||||
|
||||
/** Long enough to clear the export card's own 12-character minimum. */
|
||||
const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase";
|
||||
@@ -91,6 +115,8 @@ const RESTORED_WEIGHT_KG = 81.4;
|
||||
const UNTOUCHED_PULSE_BPM = 58;
|
||||
/** The reading that arrives AFTER the snapshot and must not survive it. */
|
||||
const POST_SNAPSHOT_PULSE_BPM = 66;
|
||||
/** The second account's reading, carried through its own backup. */
|
||||
const FRESH_ACCOUNT_WEIGHT_KG = 67.3;
|
||||
|
||||
const MEDICATION_NAME = "E2E Backup Tablet";
|
||||
|
||||
@@ -291,6 +317,45 @@ async function restoreThroughDialog(page: Page, id: string) {
|
||||
return answered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a queued restore to finish, and fail with its code if it failed.
|
||||
*
|
||||
* The restore runs on the `backup-restore` queue: the request answers 202
|
||||
* with the job and its status route, and the worker inside the server under
|
||||
* test does the work. Nothing after this is read until the job says it is
|
||||
* done, so every assertion is about the finished restore rather than a moment
|
||||
* in the middle of it.
|
||||
*/
|
||||
async function awaitRestore(page: Page, response: Response): Promise<void> {
|
||||
expect(response.status()).toBe(202);
|
||||
const queued = (await response.json()) as {
|
||||
data: { jobId: string; status: string; statusUrl: string };
|
||||
};
|
||||
expect(queued.data.status).toBe("queued");
|
||||
expect(queued.data.statusUrl).toBe(
|
||||
`/api/admin/backups/restores/${queued.data.jobId}`,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(queued.data.statusUrl);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
status: string;
|
||||
failure: { code: string } | null;
|
||||
} | null;
|
||||
};
|
||||
// A failed job ends the wait with its code in the message instead of
|
||||
// running the clock out on a state that will never change.
|
||||
return body.data?.status === "failed"
|
||||
? `failed: ${body.data.failure?.code ?? "unknown"}`
|
||||
: body.data?.status;
|
||||
},
|
||||
{ timeout: RESTORE_BUDGET_MS, intervals: [250, 500, 1_000] },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
}
|
||||
|
||||
test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
|
||||
|
||||
@@ -319,7 +384,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
tamperedCopyId = null;
|
||||
});
|
||||
|
||||
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
|
||||
test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
@@ -505,7 +570,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
expect(await storedMeasurements()).toEqual(before);
|
||||
});
|
||||
|
||||
test("the snapshot puts back what was deleted and drops what came after", async ({
|
||||
test("the snapshot puts back what was deleted and drops what came after @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
const record = requireSeeded();
|
||||
@@ -563,39 +628,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, requireSnapshot());
|
||||
// The restore runs on the `backup-restore` queue: the request answers 202
|
||||
// with the job and its status route, and the worker inside the server
|
||||
// under test does the work. Nothing below is read until the job says it
|
||||
// is done, so every assertion is about the finished restore rather than
|
||||
// a moment in the middle of it.
|
||||
expect(response.status()).toBe(202);
|
||||
const queued = (await response.json()) as {
|
||||
data: { jobId: string; status: string; statusUrl: string };
|
||||
};
|
||||
expect(queued.data.status).toBe("queued");
|
||||
expect(queued.data.statusUrl).toBe(
|
||||
`/api/admin/backups/restores/${queued.data.jobId}`,
|
||||
await awaitRestore(
|
||||
page,
|
||||
await restoreThroughDialog(page, requireSnapshot()),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(queued.data.statusUrl);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
status: string;
|
||||
failure: { code: string } | null;
|
||||
} | null;
|
||||
};
|
||||
// A failed job ends the wait with its code in the message instead
|
||||
// of running the clock out on a state that will never change.
|
||||
return body.data?.status === "failed"
|
||||
? `failed: ${body.data.failure?.code ?? "unknown"}`
|
||||
: body.data?.status;
|
||||
},
|
||||
{ timeout: 60_000, intervals: [250, 500, 1_000] },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
|
||||
const afterRestore = await storedMeasurements();
|
||||
|
||||
@@ -650,6 +686,166 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a full backup the operator restores brings the record back into a fresh account @critical", async ({
|
||||
page,
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
// The second account: a person with a record of their own, who downloads
|
||||
// the file the export card offers. Its jar is its own; the act below
|
||||
// gives it a session that needs a re-proof, like the admin's.
|
||||
const person = criticalAccount("restore", testInfo);
|
||||
await resetCriticalAccount(person, [
|
||||
"DELETE FROM measurement_rollups WHERE user_id = $1",
|
||||
"DELETE FROM measurements WHERE user_id = $1",
|
||||
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
|
||||
"DELETE FROM mood_entries WHERE user_id = $1",
|
||||
"DELETE FROM rate_limits WHERE key = 'export:' || $1",
|
||||
]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
storageState: person.storageStatePath,
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
let backupPath = "";
|
||||
let written: { weightId: string; moodId: string };
|
||||
try {
|
||||
const own = await context.newPage();
|
||||
const endOwnSession = await useStaleSession(own, person.username);
|
||||
try {
|
||||
await own.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
written = await own.evaluate(async (weightKg: number) => {
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json()) as { data: { id: string } | null };
|
||||
if (!res.ok || !json.data) {
|
||||
throw new Error(`${path} answered ${res.status}`);
|
||||
}
|
||||
return json.data.id;
|
||||
};
|
||||
const at = new Date(Date.now() - 90 * 60_000).toISOString();
|
||||
return {
|
||||
weightId: await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: weightKg,
|
||||
measuredAt: at,
|
||||
}),
|
||||
moodId: await post("/api/mood-entries", {
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: at,
|
||||
}),
|
||||
};
|
||||
}, FRESH_ACCOUNT_WEIGHT_KG);
|
||||
|
||||
await own.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
await expect(own.getByTestId("export-card-full-backup")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const download = own.waitForEvent("download");
|
||||
await actWithReproof(own, person.password, () =>
|
||||
own.getByTestId("export-action-full-backup").click(),
|
||||
);
|
||||
// Kept past the context: a download's own file goes with it.
|
||||
backupPath = testInfo.outputPath("full-backup.json");
|
||||
await (await download).saveAs(backupPath);
|
||||
} finally {
|
||||
await endOwnSession();
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// ── the account is gone, and comes back empty under the same id ──────
|
||||
// What a new host is to the runbook (`docs/ops/backup-restore.md`): the
|
||||
// backup names its owner by id, so the account is recreated under it
|
||||
// before the upload. A browser cannot express this; it is the one act
|
||||
// here that goes to Postgres.
|
||||
await recreateCriticalAccount(person);
|
||||
expect(await accountRowCounts(person)).toEqual({
|
||||
measurements: 0,
|
||||
moodEntries: 0,
|
||||
});
|
||||
|
||||
// ── the operator uploads the file and restores it ───────────────────
|
||||
await openBackupsConsole(page);
|
||||
const uploaded = page.waitForResponse(
|
||||
(res) =>
|
||||
new URL(res.url()).pathname === "/api/admin/backups/upload" &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() !== 401,
|
||||
);
|
||||
await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
|
||||
page
|
||||
.locator('input[type="file"][accept*="application/gzip"]')
|
||||
.setInputFiles(backupPath),
|
||||
);
|
||||
const upload = await uploaded;
|
||||
expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201);
|
||||
const uploadId = ((await upload.json()) as { data: { id: string } }).data
|
||||
.id;
|
||||
await expect(snapshotRow(page, uploadId)).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitRestore(page, await restoreThroughDialog(page, uploadId));
|
||||
|
||||
// ── and the person signs in to their record ──────────────────────────
|
||||
const back = await browser.newContext({
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
try {
|
||||
await clearLoginBuckets();
|
||||
const login = await back.request.post("/api/auth/login", {
|
||||
data: { email: person.username, password: person.password },
|
||||
});
|
||||
expect(login.status()).toBe(200);
|
||||
const signedIn = await back.newPage();
|
||||
|
||||
// The same rows, under their own ids: a restore, not a re-entry.
|
||||
const measurements = (
|
||||
(await (await signedIn.request.get("/api/measurements")).json()) as {
|
||||
data: { measurements: Array<{ id: string; value: number }> };
|
||||
}
|
||||
).data.measurements;
|
||||
expect(measurements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: written.weightId,
|
||||
value: FRESH_ACCOUNT_WEIGHT_KG,
|
||||
}),
|
||||
]);
|
||||
const moods = (
|
||||
(await (await signedIn.request.get("/api/mood-entries")).json()) as {
|
||||
data: { entries: Array<{ id: string; mood: string }> };
|
||||
}
|
||||
).data.entries;
|
||||
expect(moods).toEqual([
|
||||
expect.objectContaining({ id: written.moodId, mood: "OKAY" }),
|
||||
]);
|
||||
|
||||
await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
signedIn
|
||||
.locator(
|
||||
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
|
||||
`[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await back.close();
|
||||
}
|
||||
|
||||
// The admin's own record is untouched by a restore aimed at somebody else.
|
||||
expect(
|
||||
(await storedMeasurements()).filter((m) => m.deletedAt === null),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("A read-level delegate inside the record", () => {
|
||||
|
||||
@@ -85,6 +85,29 @@ async function shellReady(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the open palette once it has finished opening. The dialog fades in,
|
||||
* and a scan that lands mid-fade reads every colour blended with the page
|
||||
* behind it: on a loaded machine the selected option's text measured 4.47:1
|
||||
* against a 4.5:1 floor it clears once the fade ends. Finite animations are
|
||||
* awaited, bounded, so a stuck one costs the wait and not the test.
|
||||
*/
|
||||
async function scanPalette(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const finite = document
|
||||
.getAnimations()
|
||||
.filter((a) => a.effect?.getTiming().iterations !== Infinity);
|
||||
await Promise.race([
|
||||
Promise.allSettled(finite.map((a) => a.finished)),
|
||||
new Promise((resolve) => window.setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
});
|
||||
return new AxeBuilder({ page })
|
||||
.include('[data-slot="command-palette"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
}
|
||||
|
||||
test.describe("command palette", () => {
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(
|
||||
@@ -181,10 +204,7 @@ test.describe("command palette", () => {
|
||||
page.locator('[data-group="records"] mark').first(),
|
||||
).toHaveText("Shoulder");
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="command-palette"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const axe = await scanPalette(page);
|
||||
expect(axe.violations).toEqual([]);
|
||||
|
||||
// The arrows reach it after the local options, and Enter opens it.
|
||||
@@ -226,10 +246,7 @@ test.describe("command palette", () => {
|
||||
await expect(palette(page)).toBeVisible();
|
||||
await expect(input(page)).toBeFocused();
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="command-palette"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
const axe = await scanPalette(page);
|
||||
expect(axe.violations).toEqual([]);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -332,6 +332,40 @@ test.describe("the day view", () => {
|
||||
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("beside the docked day, every blood-pressure value keeps its unit on its line", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"docked from 1280 px",
|
||||
);
|
||||
// The narrowest docked width: each half of the stat card is narrowest
|
||||
// here. Four cells a row left "1xx mmHg" no room, so the systolic values
|
||||
// broke onto two lines while the two-digit diastolic ones fitted.
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/insights/blood-pressure?day=${isoDaysAgo(3)}`);
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
||||
const values = page.locator(
|
||||
'[data-slot="metric-stat-series"] [data-slot="metric-stat"] > p:nth-child(2)',
|
||||
);
|
||||
await expect(values).toHaveCount(8);
|
||||
const cells = await values.evaluateAll((els) =>
|
||||
els.map((el) => {
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||
return {
|
||||
text: el.textContent ?? "",
|
||||
lines: Math.round(el.getBoundingClientRect().height / lineHeight),
|
||||
overflow: el.scrollWidth - el.clientWidth,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const cell of cells) {
|
||||
expect(cell.text, cell.text).toMatch(/mmHg$/);
|
||||
expect(cell.lines, cell.text).toBe(1);
|
||||
expect(cell.overflow, cell.text).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("the strip runs the full height; the search magnifier stays clear of it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -136,7 +136,7 @@ test.describe("delegated writes", () => {
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("the owner invites at a level that may add entries", async () => {
|
||||
test("the owner invites at a level that may add entries @critical", async () => {
|
||||
await openInviteForm();
|
||||
|
||||
const identifier = ownerPage.locator(
|
||||
@@ -183,7 +183,9 @@ test.describe("delegated writes", () => {
|
||||
).toBe(WRITE_LEVEL_VALUE);
|
||||
});
|
||||
|
||||
test("the delegate accepts and opens the record", async ({ page }) => {
|
||||
test("the delegate accepts and opens the record @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/access");
|
||||
await page.locator('[data-slot="grant-accept"]').first().click();
|
||||
|
||||
@@ -203,7 +205,9 @@ test.describe("delegated writes", () => {
|
||||
await expect(banner).not.toContainText("read it, not change it");
|
||||
});
|
||||
|
||||
test("the reading they add lands in the owner's record", async ({ page }) => {
|
||||
test("the reading they add lands in the owner's record @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/measurements");
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { Page, Response } from "@playwright/test";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
import { readPdfText } from "@/lib/documents/pdfjs";
|
||||
|
||||
import {
|
||||
E2E_REPORT_OWNER,
|
||||
@@ -342,13 +343,7 @@ async function generateReport(page: Page): Promise<GeneratedReport> {
|
||||
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
|
||||
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
|
||||
|
||||
const parser = new PDFParse({ data: new Uint8Array(bytes) });
|
||||
let text: string;
|
||||
try {
|
||||
text = (await parser.getText()).text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
const text = await readPdfText(new Uint8Array(bytes));
|
||||
|
||||
return {
|
||||
status: response.status(),
|
||||
|
||||
+12
-8
@@ -20,15 +20,19 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import {
|
||||
ensureVaultFixture,
|
||||
seedNamespaceDocs,
|
||||
KNIE_EPISODE_ID,
|
||||
MRT_DOC_ID,
|
||||
vaultFixtureIds,
|
||||
} from "./setup/vault-fixture";
|
||||
|
||||
// The vault on an account of its own (`E2E_VAULT`): its layout is account
|
||||
// state, and the month-grid tests read the default.
|
||||
const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } =
|
||||
vaultFixtureIds(E2E_VAULT.username);
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 26_214_400;
|
||||
|
||||
/** Scroll the shell's scroll container until `locator` is visible (the
|
||||
@@ -145,10 +149,10 @@ test.describe("document vault", () => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
});
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
test.use({ storageState: VAULT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
await ensureVaultFixture(E2E_VAULT.username);
|
||||
});
|
||||
|
||||
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
|
||||
@@ -532,7 +536,7 @@ test.describe("document vault", () => {
|
||||
test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(90_000);
|
||||
await seedNamespaceDocs("bulkfifty", 50);
|
||||
await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username);
|
||||
|
||||
// Shared filing date → the namespace orders by id desc: "050" first,
|
||||
// "001" last. Anchor on the first card, shift-click the last.
|
||||
@@ -568,7 +572,7 @@ test.describe("document vault", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "bulk selection is a desktop pointer flow");
|
||||
await seedNamespaceDocs("bulkpf", 5);
|
||||
await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username);
|
||||
|
||||
await page.goto("/documents?q=bulkpf");
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
@@ -603,7 +607,7 @@ test.describe("document vault", () => {
|
||||
}) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(120_000);
|
||||
await seedNamespaceDocs("bulkchunk", 101);
|
||||
await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username);
|
||||
|
||||
// Newest-first by id tiebreak: "101" renders first, "001" last. Walk
|
||||
// the infinite query to the end (pages of 50) so the range can span
|
||||
|
||||
@@ -62,8 +62,14 @@ function decimal(value: string): RegExp {
|
||||
return new RegExp(value.replace(".", "[.,]"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The list, filtered to blood glucose. Unfiltered it shows the account's
|
||||
* newest page of every type, and other specs keep writing readings to the
|
||||
* same account, so on a database a few runs old a reading typed for
|
||||
* "yesterday" falls off that page and the count reads one short.
|
||||
*/
|
||||
async function openList(page: Page): Promise<void> {
|
||||
await page.goto("/measurements");
|
||||
await page.goto("/measurements?type=BLOOD_GLUCOSE");
|
||||
await expect(page.locator(LIST_SETTLED).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
+231
-47
@@ -1,24 +1,89 @@
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
clearLoginBuckets,
|
||||
criticalAccount,
|
||||
resetCriticalAccount,
|
||||
type CriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
|
||||
/**
|
||||
* Smoke checks on the login surface — must render, must wire the
|
||||
* password-manager autofill hints (autoComplete="username" /
|
||||
* "current-password"), must let the user submit.
|
||||
* Signing in and out, against the real server.
|
||||
*
|
||||
* These do NOT log in (no DB seed required); the password-flow
|
||||
* roundtrip is exercised by the integration test in
|
||||
* tests/integration/auth-flow.test.ts.
|
||||
* Every test starts without a session and signs in through the form, so what
|
||||
* it reads is what the login route, the session table and the shell did, not
|
||||
* a payload typed into the test. The account is the sign-in journey's own
|
||||
* (one per project, see `critical-fixture.ts`): it gets no jar from global
|
||||
* setup, because the tests here end the sessions they open and a jar captured
|
||||
* once would be dead for a retry.
|
||||
*
|
||||
* The passkey half runs through Chromium's virtual authenticator, attached
|
||||
* over CDP. It is a real authenticator as far as the page and the server are
|
||||
* concerned: the registration and the assertion are signed, and the server
|
||||
* verifies both against the origin and RP id the suite runs under.
|
||||
*/
|
||||
async function openPasswordForm(page: import("@playwright/test").Page) {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
const SESSION_COOKIE = "healthlog_session";
|
||||
|
||||
async function openPasswordForm(page: Page): Promise<void> {
|
||||
await page.goto("/auth/login");
|
||||
// The login page defaults to passkey mode; the password form is
|
||||
// mounted only after clicking the "Login with password" / "Mit
|
||||
// Passwort anmelden" outline button.
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /sign in with password|login with password|mit passwort/i,
|
||||
})
|
||||
.click();
|
||||
// The login page opens in passkey mode; the password form mounts after
|
||||
// this button. The button is server-rendered, so the click is retried
|
||||
// until the form it mounts is there.
|
||||
const usePassword = page.getByTestId("login-use-password");
|
||||
const email = page.getByTestId("login-email");
|
||||
await expect(async () => {
|
||||
await usePassword.click({ timeout: 2_000 });
|
||||
await expect(email).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function signInWithPassword(
|
||||
page: Page,
|
||||
account: CriticalAccount,
|
||||
): Promise<void> {
|
||||
await openPasswordForm(page);
|
||||
await page.getByTestId("login-email").fill(account.username);
|
||||
await page.getByTestId("login-password").fill(account.password);
|
||||
// The login route allows five attempts per IP per quarter hour and the
|
||||
// whole suite is one IP; see `clearLoginBuckets`.
|
||||
await clearLoginBuckets();
|
||||
await page.getByTestId("login-password-submit").click();
|
||||
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function sessionCookie(context: BrowserContext): Promise<string> {
|
||||
const cookie = (await context.cookies()).find(
|
||||
(c) => c.name === SESSION_COOKIE,
|
||||
);
|
||||
expect(cookie, "the login answered without a session cookie").toBeTruthy();
|
||||
return cookie!.value;
|
||||
}
|
||||
|
||||
/** The username `/api/auth/me` answers for this context's session. */
|
||||
async function signedInAs(page: Page): Promise<string | null> {
|
||||
const res = await page.request.get("/api/auth/me");
|
||||
if (res.status() !== 200) return null;
|
||||
return ((await res.json()) as { data: { username: string } }).data.username;
|
||||
}
|
||||
|
||||
async function signOutThroughTheMenu(page: Page): Promise<void> {
|
||||
// The desktop sidebar and the phone top bar each carry a user menu; only
|
||||
// one of them is on screen at a given width.
|
||||
const trigger = page
|
||||
.getByRole("button", { name: "User menu" })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const signOut = page.getByRole("menuitem", { name: "Sign out" });
|
||||
await expect(async () => {
|
||||
await trigger.click({ timeout: 2_000 });
|
||||
await expect(signOut).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
await signOut.click();
|
||||
await page.waitForURL((url) => url.pathname === "/auth/login");
|
||||
}
|
||||
|
||||
test.describe("login page", () => {
|
||||
@@ -27,47 +92,166 @@ test.describe("login page", () => {
|
||||
}) => {
|
||||
await openPasswordForm(page);
|
||||
|
||||
const username = page
|
||||
.getByLabel(/username|benutzername|email|e-mail/i)
|
||||
.first();
|
||||
const username = page.getByTestId("login-email");
|
||||
await expect(username).toBeVisible();
|
||||
await expect(username).toHaveAttribute("autoComplete", /username|email/);
|
||||
|
||||
const password = page.locator('input[type="password"]').first();
|
||||
const password = page.getByTestId("login-password");
|
||||
await expect(password).toBeVisible();
|
||||
await expect(password).toHaveAttribute("autoComplete", "current-password");
|
||||
});
|
||||
|
||||
test("rejects an obviously-wrong credential pair", async ({ page }) => {
|
||||
test("refuses a wrong password and opens no session", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const account = criticalAccount("auth", testInfo);
|
||||
await openPasswordForm(page);
|
||||
|
||||
await page
|
||||
.getByLabel(/username|benutzername|email|e-mail/i)
|
||||
.first()
|
||||
.fill("nobody-here");
|
||||
await page.locator('input[type="password"]').first().fill("not-the-pw");
|
||||
|
||||
// Intercept the API call so the test does not depend on a real
|
||||
// backend rejecting the credentials. The login form's error
|
||||
// surfacing is what we actually want to prove here.
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Invalid credentials" }),
|
||||
}),
|
||||
await page.getByTestId("login-email").fill(account.username);
|
||||
await page.getByTestId("login-password").fill("not-this-account-password");
|
||||
await clearLoginBuckets();
|
||||
const answer = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().endsWith("/api/auth/login") &&
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
await page.getByTestId("login-password-submit").click();
|
||||
|
||||
// The form has multiple buttons matching /sign in/ — both the
|
||||
// passkey CTA and the password submit. Filter to the submit button
|
||||
// so the click lands on the right one.
|
||||
await page
|
||||
.locator('button[type="submit"]')
|
||||
.filter({ hasText: /login|anmelden|sign in/i })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/invalid credentials|ungültig|falsch/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
expect((await answer).status()).toBe(401);
|
||||
await expect(page.getByText("Invalid credentials")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/auth\/login/);
|
||||
expect(
|
||||
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("signing in and out", () => {
|
||||
test("a password sign-in lands on the dashboard as that account @critical", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const account = criticalAccount("auth", testInfo);
|
||||
await signInWithPassword(page, account);
|
||||
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
expect(await signedInAs(page)).toBe(account.username);
|
||||
});
|
||||
|
||||
test("signing out ends the session on the server, not only in the browser @critical", async ({
|
||||
page,
|
||||
context,
|
||||
playwright,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
const account = criticalAccount("auth", testInfo);
|
||||
await signInWithPassword(page, account);
|
||||
const cookie = await sessionCookie(context);
|
||||
// A second client holding the same cookie, the way a copied jar or a
|
||||
// stolen cookie would.
|
||||
const replay = await playwright.request.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
|
||||
});
|
||||
try {
|
||||
// Positive control: the replayed cookie is a working session before
|
||||
// the sign-out, so the 401 below is about the sign-out.
|
||||
expect((await replay.get("/api/auth/me")).status()).toBe(200);
|
||||
|
||||
await signOutThroughTheMenu(page);
|
||||
|
||||
// The browser dropped its cookie; that alone would pass with a server
|
||||
// that kept the row. The proof is the old cookie being refused.
|
||||
expect(await signedInAs(page)).toBeNull();
|
||||
expect((await replay.get("/api/auth/me")).status()).toBe(401);
|
||||
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
|
||||
401,
|
||||
);
|
||||
} finally {
|
||||
await replay.dispose();
|
||||
}
|
||||
|
||||
// And the protected shell sends the browser back to the form.
|
||||
await page.goto("/");
|
||||
await page.waitForURL((url) => url.pathname === "/auth/login");
|
||||
});
|
||||
|
||||
test("a passkey registered in settings signs the account back in @critical", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const account = criticalAccount("auth", testInfo);
|
||||
// A retry, or the last run, may have left a credential on the account;
|
||||
// the list below has to count only the one this test registers.
|
||||
await resetCriticalAccount(account, [
|
||||
"DELETE FROM passkeys WHERE user_id = $1",
|
||||
]);
|
||||
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable");
|
||||
const { authenticatorId } = await cdp.send(
|
||||
"WebAuthn.addVirtualAuthenticator",
|
||||
{
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await signInWithPassword(page, account);
|
||||
|
||||
await test.step("register a passkey under the current password", async () => {
|
||||
await page.goto("/settings/security");
|
||||
await page.getByRole("button", { name: "Add passkey" }).click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('input[type="password"]').fill(account.password);
|
||||
await dialog.getByRole("button", { name: "Verify and add" }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(
|
||||
page.getByTestId("passkeys-list").locator("li"),
|
||||
).toHaveCount(1);
|
||||
const stored = await cdp.send("WebAuthn.getCredentials", {
|
||||
authenticatorId,
|
||||
});
|
||||
expect(stored.credentials).toHaveLength(1);
|
||||
expect(stored.credentials[0].isResidentCredential).toBe(true);
|
||||
});
|
||||
|
||||
await signOutThroughTheMenu(page);
|
||||
expect(await signedInAs(page)).toBeNull();
|
||||
|
||||
await test.step("sign in with it", async () => {
|
||||
await clearLoginBuckets();
|
||||
await page
|
||||
.getByRole("button", { name: "Sign in with Passkey" })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(await signedInAs(page)).toBe(account.username);
|
||||
|
||||
// The server recorded the use against the stored credential.
|
||||
const res = await page.request.get("/api/auth/passkeys");
|
||||
expect(res.status()).toBe(200);
|
||||
const passkeys = (
|
||||
(await res.json()) as {
|
||||
data: Array<{ lastUsedAt: string | null }>;
|
||||
}
|
||||
).data;
|
||||
expect(passkeys).toHaveLength(1);
|
||||
expect(passkeys[0].lastUsedAt).not.toBeNull();
|
||||
});
|
||||
} finally {
|
||||
await cdp
|
||||
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
+111
-148
@@ -1,140 +1,92 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
criticalAccount,
|
||||
criticalStorageState,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
import { openMenu } from "./open-menu";
|
||||
|
||||
/**
|
||||
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
|
||||
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
|
||||
* the resulting query invalidation that surfaces the row on
|
||||
* `/measurements`.
|
||||
* Add-measurement flow, against the real server: the dashboard's quick-entry
|
||||
* menu, the dialog-mounted MeasurementForm, the real POST to
|
||||
* `/api/measurements`, and then every surface that has to agree about the
|
||||
* reading afterwards: the dashboard tile, the weight chart (through the data
|
||||
* table that lists exactly the points it drew) and the day view.
|
||||
*
|
||||
* Weight is the simplest type (single numeric input + datetime), so it
|
||||
* keeps the spec focused on the round-trip rather than the BP three-field
|
||||
* batched submit.
|
||||
* Until v1.46 this spec answered its own POST and its own list read with a
|
||||
* route stub, so it proved that the list renders a row the test typed in.
|
||||
* Nothing here is stubbed now. The account is the measurement journey's own,
|
||||
* one per project (`critical-fixture.ts`), and is emptied of readings before
|
||||
* the test, so "the tile shows the weight" can only mean the weight this test
|
||||
* entered.
|
||||
*
|
||||
* Weight is the simplest type (single numeric input + datetime), so it keeps
|
||||
* the spec on the round trip rather than on the blood-pressure batch.
|
||||
*/
|
||||
|
||||
/** Distinct from anything a fixture seeds, so a match is this reading. */
|
||||
const WEIGHT_KG = "81.7";
|
||||
|
||||
/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */
|
||||
function todayInBerlin(): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
/** The account's weight readings, as the list route answers them. */
|
||||
async function storedWeights(
|
||||
page: Page,
|
||||
): Promise<Array<{ value: number; unit: string }>> {
|
||||
const res = await page.request.get("/api/measurements?type=WEIGHT");
|
||||
expect(res.status()).toBe(200);
|
||||
const { data } = (await res.json()) as {
|
||||
data: { measurements: Array<{ value: number; unit: string }> };
|
||||
};
|
||||
return data.measurements.map(({ value, unit }) => ({ value, unit }));
|
||||
}
|
||||
|
||||
test.describe("add measurement flow", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
test.use({ storageState: criticalStorageState("measurement") });
|
||||
|
||||
// This journey lands on the dashboard, which is the most expensive page in
|
||||
// the product to render cold: the route segment suspends behind the server
|
||||
// render and shows `loading.tsx` until it resolves. On a shared runner with
|
||||
// the whole suite in flight that has overrun the 30 s default, and the test
|
||||
// then died on the quick-add trigger rather than on anything it is about.
|
||||
// The subject here is the add-then-appears round trip, not the dashboard's
|
||||
// cold-start latency, so the budget is the page's and the assertions stay
|
||||
// exactly as tight as they were.
|
||||
// the product to render cold, and then reads three more surfaces. The
|
||||
// assertions stay as tight as they are; the budget is the pages'.
|
||||
test.setTimeout(90_000);
|
||||
|
||||
test("creating a weight reading surfaces it in the list", async ({
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// Fixture hygiene: what a previous run or a retry left on the account.
|
||||
await resetCriticalAccount(criticalAccount("measurement", testInfo), [
|
||||
"DELETE FROM measurement_rollups WHERE user_id = $1",
|
||||
"DELETE FROM measurements WHERE user_id = $1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Capture the POST payload so we can assert against it independently
|
||||
// of any list-fetch response Playwright might cache. Match both
|
||||
// `/api/measurements` (POST creates a reading) and the list query
|
||||
// `/api/measurements?type=...&limit=...&offset=...` (GET).
|
||||
let postedBody: unknown = null;
|
||||
await page.route("**/api/measurements*", async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
postedBody = JSON.parse(req.postData() ?? "null");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { id: "m_e2e_1", success: true },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GET — return the just-posted reading so the list view shows it.
|
||||
// The list expects `data.measurements: Measurement[]` plus
|
||||
// `data.meta.total`; chart fetches use the same envelope.
|
||||
const body = postedBody as {
|
||||
type?: string;
|
||||
value?: number;
|
||||
measuredAt?: string;
|
||||
} | null;
|
||||
// The list-page `Measurement` shape requires `unit` and `source`
|
||||
// — without them the row crashes the render and the e2e poll
|
||||
// never sees "78.4". Mock with the same fields a real reading
|
||||
// carries.
|
||||
const measurements = body
|
||||
? [
|
||||
{
|
||||
id: "m_e2e_1",
|
||||
type: body.type,
|
||||
value: body.value,
|
||||
unit: body.type === "WEIGHT" ? "kg" : "",
|
||||
source: "MANUAL",
|
||||
measuredAt: body.measuredAt,
|
||||
notes: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements, meta: { total: measurements.length } },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Dashboard's analytics fetch — empty summaries are fine; we only
|
||||
// need the page to render so we can click "Add".
|
||||
//
|
||||
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
|
||||
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
|
||||
// requests in parallel; the previous string glob missed the slim
|
||||
// variant which then fell through to the real route and slowed
|
||||
// the page paint enough that the post-mutation list query never
|
||||
// surfaced inside the 10 s window.
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
|
||||
// at the top-right of `<main>`. Scope the locator there so we don't
|
||||
// accidentally match an "Add" button on the sidebar.
|
||||
const main = page.locator("main");
|
||||
const addTrigger = main
|
||||
// The quick-entry trigger sits at the top of `<main>`. Waiting for it,
|
||||
// rather than for the route skeleton to go, is waiting for the thing the
|
||||
// next line clicks.
|
||||
const addTrigger = page
|
||||
.locator("main")
|
||||
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
|
||||
.first();
|
||||
// Wait for the trigger itself rather than for the route-level skeleton to
|
||||
// go. The skeleton is also absent while the protected shell holds the
|
||||
// whole page behind its hydration gate, so its disappearance is not a
|
||||
// statement about the suspended segment having resolved; the control the
|
||||
// next line clicks is. `networkidle` says nothing about either.
|
||||
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
// The account holds no weight yet, so whatever the surfaces below show
|
||||
// can only come from this reading.
|
||||
expect(await storedWeights(page)).toEqual([]);
|
||||
|
||||
await openMenu(page, addTrigger);
|
||||
|
||||
// v1.5: the menu items now have distinct labels — the
|
||||
// measurement entry says "Measurement" / "Messung" instead of "Add",
|
||||
// so we can target it directly.
|
||||
// The desktop rail opens a dropdown, the phone shell the capture sheet
|
||||
// the bottom nav's plus opens; the measurement entry is in either.
|
||||
await page
|
||||
@@ -143,52 +95,63 @@ test.describe("add measurement flow", () => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Form should be visible inside the dialog. Switch type to WEIGHT
|
||||
// (combo-box defaults to BLOOD_PRESSURE).
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Select trigger renders the current label — click to open and
|
||||
// pick the weight option.
|
||||
// The type select defaults to blood pressure.
|
||||
await dialog.getByRole("combobox").first().click();
|
||||
await page
|
||||
.getByRole("option", { name: /weight|gewicht/i })
|
||||
.first()
|
||||
.click();
|
||||
await dialog.locator("#value").fill(WEIGHT_KG);
|
||||
|
||||
// Weight is single-value mode — fill the value field with 78.4 kg.
|
||||
const valueInput = dialog.locator("#value");
|
||||
await valueInput.fill("78.4");
|
||||
|
||||
// Save
|
||||
const created = page.waitForResponse(
|
||||
(res) =>
|
||||
new URL(res.url()).pathname === "/api/measurements" &&
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
await dialog.getByRole("button", { name: /save|speichern/i }).click();
|
||||
const response = await created;
|
||||
expect(response.status()).toBe(201);
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
// Dialog closes when the mutation succeeds
|
||||
await expect(dialog).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
// Assert the POST went through with the expected shape
|
||||
expect(postedBody).toMatchObject({
|
||||
type: "WEIGHT",
|
||||
value: 78.4,
|
||||
await test.step("the server stored exactly that reading", async () => {
|
||||
expect(await storedWeights(page)).toEqual([
|
||||
{ value: Number(WEIGHT_KG), unit: "kg" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Navigate to the list and confirm the entry surfaces. The list
|
||||
// page renders entries from /api/measurements which we've mocked
|
||||
// to echo the just-posted row.
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
await test.step("the dashboard tile", async () => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const tile = page.locator(
|
||||
'[data-slot="dashboard-tile-link"][data-tile-id="weight"]',
|
||||
);
|
||||
await expect(tile).toBeVisible({ timeout: 60_000 });
|
||||
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
|
||||
WEIGHT_KG,
|
||||
);
|
||||
});
|
||||
|
||||
// The list renders a desktop table AND a mobile card grid (one is
|
||||
// hidden via `md:hidden`/`hidden md:block`). At least one match
|
||||
// means our row landed in the DOM regardless of viewport.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page
|
||||
.locator("main")
|
||||
.getByText(/78[.,]4/)
|
||||
.count(),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await test.step("the weight chart", async () => {
|
||||
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
|
||||
const table = page.locator('[data-slot="chart-data-table"]').first();
|
||||
await expect(table).toBeVisible({ timeout: 30_000 });
|
||||
await table.locator('[data-slot="chart-data-table-toggle"]').click();
|
||||
const rows = table.locator('[data-slot="chart-data-table-row"]');
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(WEIGHT_KG);
|
||||
});
|
||||
|
||||
await test.step("the day view", async () => {
|
||||
await page.goto(`/measurements?day=${todayInBerlin()}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const value = page.locator(
|
||||
'[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]',
|
||||
);
|
||||
await expect(value).toBeVisible({ timeout: 30_000 });
|
||||
await expect(value).toContainText(WEIGHT_KG);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
|
||||
test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The wizard is a seven-step dialog with per-step validation gating Next;
|
||||
|
||||
@@ -2,6 +2,11 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
criticalAccount,
|
||||
criticalStorageState,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
@@ -37,9 +42,16 @@ const CASES: readonly Case[] = [
|
||||
|
||||
async function expectNoSeriousViolations(page: Page, label: string) {
|
||||
// The pointer rests where the last tap landed; a hover tint under it is
|
||||
// not what the screen shows at rest.
|
||||
// not what the screen shows at rest. Moving it away starts the tint's
|
||||
// transition back, so the scan waits for every transition to settle rather
|
||||
// than for a guessed number of milliseconds. Transitions only: a spinner's
|
||||
// or a skeleton's endless animation would never settle.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForTimeout(300);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.getAnimations()
|
||||
.every((a) => !(a instanceof CSSTransition) || a.playState !== "running"),
|
||||
);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include(
|
||||
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
|
||||
@@ -171,3 +183,352 @@ test.describe("mental-health check-in, every instrument", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The questionnaires in the reader's language. Where a validated translation
|
||||
* exists the form shows it and nothing else; where the text is HealthLog's
|
||||
* own translation it carries the note and the one-tap switch to the English
|
||||
* original, and the result says it was answered in an unvalidated
|
||||
* translation. Which cells are unvalidated is pinned by
|
||||
* `instrument-wording.test.ts`; this journey proves the surface follows it.
|
||||
*/
|
||||
const UNVALIDATED: Record<string, readonly Case["id"][]> = {
|
||||
de: ["SCI"],
|
||||
nl: ["SCI"],
|
||||
ko: ["SCI"],
|
||||
"pt-BR": ["WHO5", "SCI"],
|
||||
};
|
||||
|
||||
function caseOf(id: Case["id"]): Case {
|
||||
const found = CASES.find((c) => c.id === id);
|
||||
if (!found) throw new Error(`no case for ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function openInLocale(page: Page, locale: string) {
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "healthlog-locale",
|
||||
value: locale,
|
||||
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
await page.goto("/mental-wellbeing");
|
||||
}
|
||||
|
||||
async function answerAll(page: Page, c: Case, position: number) {
|
||||
const wizard = page.locator(
|
||||
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
|
||||
);
|
||||
for (let step = 1; step <= c.items; step++) {
|
||||
const current = wizard.locator(
|
||||
`[data-slot="check-in-step"][data-step="${step}"]`,
|
||||
);
|
||||
await expect(current).toBeVisible();
|
||||
await current
|
||||
.locator('[role="radio"]')
|
||||
.nth(position - 1)
|
||||
.click();
|
||||
}
|
||||
if (c.followUp) {
|
||||
await wizard.locator('[data-slot="check-in-next"]').click();
|
||||
}
|
||||
await expect(wizard.locator('[data-slot="check-in-review"]')).toBeVisible();
|
||||
await wizard.locator('[data-slot="check-in-submit"]').click();
|
||||
const result = page.locator(
|
||||
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
|
||||
);
|
||||
await expect(result).toBeVisible();
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe("mental-health check-in, in the reader's language", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
for (const [locale, unvalidated] of Object.entries(UNVALIDATED)) {
|
||||
test(`${locale}: the note and the switch appear only on an unvalidated translation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await openInLocale(page, locale);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale);
|
||||
|
||||
for (const c of CASES) {
|
||||
await page
|
||||
.locator(`[data-slot="instrument-card"][data-instrument="${c.id}"]`)
|
||||
.locator('[data-slot="instrument-card-start"]')
|
||||
.click();
|
||||
const wizard = page.locator(
|
||||
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
|
||||
);
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-step"][data-step="1"]'),
|
||||
).toBeVisible();
|
||||
const expected = unvalidated.includes(c.id) ? 1 : 0;
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
|
||||
).toHaveCount(expected);
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-wording-toggle"]'),
|
||||
).toHaveCount(expected);
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-question"]'),
|
||||
).toHaveAttribute("lang", locale);
|
||||
if (expected === 1) {
|
||||
// On screen together with the question, at every width: the step
|
||||
// scrolls to its top block, note and stem included.
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
|
||||
).toBeInViewport();
|
||||
await expectNoSeriousViolations(page, `${locale} ${c.id}`);
|
||||
}
|
||||
await wizard.locator('[data-slot="check-in-back-to-landing"]').click();
|
||||
}
|
||||
|
||||
// An unvalidated translation answered as shown: the result says so.
|
||||
const sci = caseOf("SCI");
|
||||
await page
|
||||
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
|
||||
.locator('[data-slot="instrument-card-start"]')
|
||||
.click();
|
||||
const result = await answerAll(page, sci, 2);
|
||||
await expect(
|
||||
result.locator('[data-slot="result-wording-note"]'),
|
||||
).toBeVisible();
|
||||
await expectNoSeriousViolations(page, `${locale} SCI result`);
|
||||
});
|
||||
}
|
||||
|
||||
test("nl: the switch shows the English original, and the result records it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await openInLocale(page, "nl");
|
||||
await page
|
||||
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
|
||||
.locator('[data-slot="instrument-card-start"]')
|
||||
.click();
|
||||
const wizard = page.locator(
|
||||
'[data-slot="mental-health-check-in"][data-instrument="SCI"]',
|
||||
);
|
||||
const question = wizard.locator('[data-slot="check-in-question"]');
|
||||
const translated = await question.textContent();
|
||||
// The note and its switch travel with every step, not only the first.
|
||||
for (const step of [1, 2]) {
|
||||
const current = wizard.locator(
|
||||
`[data-slot="check-in-step"][data-step="${step}"]`,
|
||||
);
|
||||
await expect(
|
||||
current.locator('[data-slot="check-in-unvalidated-note"]'),
|
||||
).toBeInViewport();
|
||||
await current.locator('[role="radio"]').first().click();
|
||||
}
|
||||
await wizard.locator('[data-slot="check-in-back"]').click();
|
||||
await wizard.locator('[data-slot="check-in-back"]').click();
|
||||
await expect(question).toHaveText(translated ?? "");
|
||||
const toggle = wizard.locator('[data-slot="check-in-wording-toggle"]');
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-showing", "original_en");
|
||||
await expect(question).toHaveAttribute("lang", "en");
|
||||
await expect(question).not.toHaveText(translated ?? "");
|
||||
const sci = caseOf("SCI");
|
||||
const result = await answerAll(page, sci, 1);
|
||||
await expect(
|
||||
result.locator('[data-slot="result-wording-note"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pt-BR: PHQ-9 item 9 above zero leads the result with the crisis card", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await openInLocale(page, "pt-BR");
|
||||
await page
|
||||
.locator('[data-slot="instrument-card"][data-instrument="PHQ9"]')
|
||||
.locator('[data-slot="instrument-card-start"]')
|
||||
.click();
|
||||
const phq = caseOf("PHQ9");
|
||||
// Position 2 is "Vários dias" (value 1) on every item, item 9 included.
|
||||
const result = await answerAll(page, phq, 2);
|
||||
await expect(
|
||||
result.locator('[data-slot="mental-health-crisis-card"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
result.locator('[data-slot="result-wording-note"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A WHO-5 check-in, word for word, in German and in Brazilian Portuguese,
|
||||
* on the wellbeing journey's own account (one per project,
|
||||
* `critical-fixture.ts`) against the real server.
|
||||
*
|
||||
* The journeys above anchor on data attributes so they read the same in every
|
||||
* language; this one reads the words. The German text is the validated WHO
|
||||
* translation, which the form has to reproduce exactly; the Brazilian one is
|
||||
* HealthLog's own and has to say so. Either way the stored assessment records
|
||||
* the language and the kind of wording it was answered in, and the score the
|
||||
* server computed is the one on screen.
|
||||
*
|
||||
* The strings are written out here rather than imported from
|
||||
* `src/lib/mental-health/wording/`: a test that read the expected text from
|
||||
* the module it checks would pass on any edit to that module.
|
||||
*/
|
||||
const WHO5_WORDING = {
|
||||
de: {
|
||||
validated: true,
|
||||
progress: "Frage 1 von 5",
|
||||
stem: "In den letzten zwei Wochen …",
|
||||
first: "… war ich froh und guter Laune",
|
||||
last: "… war mein Alltag voller Dinge, die mich interessieren",
|
||||
// In the order the form prints them, best first.
|
||||
anchors: [
|
||||
"Die ganze Zeit",
|
||||
"Meistens",
|
||||
"Etwas mehr als die Hälfte der Zeit",
|
||||
"Etwas weniger als die Hälfte der Zeit",
|
||||
"Ab und zu",
|
||||
"Zu keinem Zeitpunkt",
|
||||
],
|
||||
},
|
||||
"pt-BR": {
|
||||
validated: false,
|
||||
progress: "Pergunta 1 de 5",
|
||||
stem: "Nas últimas duas semanas …",
|
||||
first: "Eu me senti alegre e de bom humor",
|
||||
last: "Meu dia a dia foi preenchido com coisas que me interessam",
|
||||
anchors: [
|
||||
"O tempo todo",
|
||||
"A maior parte do tempo",
|
||||
"Mais da metade do tempo",
|
||||
"Menos da metade do tempo",
|
||||
"Algumas vezes",
|
||||
"Em nenhum momento",
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** The account's stored assessments, as the list route answers them. */
|
||||
async function storedAssessments(page: Page): Promise<
|
||||
Array<{
|
||||
instrument: string;
|
||||
locale: string;
|
||||
wording: string | null;
|
||||
totalScore: number;
|
||||
}>
|
||||
> {
|
||||
const res = await page.request.get("/api/mental-health/assessments");
|
||||
expect(res.status()).toBe(200);
|
||||
const { data } = (await res.json()) as {
|
||||
data: {
|
||||
assessments: Array<{
|
||||
instrument: string;
|
||||
locale: string;
|
||||
wording: string | null;
|
||||
totalScore: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return data.assessments.map(
|
||||
({ instrument, locale, wording, totalScore }) => ({
|
||||
instrument,
|
||||
locale,
|
||||
wording,
|
||||
totalScore,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("WHO-5 check-in, word for word", () => {
|
||||
test.use({ storageState: criticalStorageState("wellbeing") });
|
||||
// Both locales write to the one account of the project, and each test
|
||||
// empties it first; in order, so neither empties the other's mid-run.
|
||||
test.describe.configure({ mode: "default" });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// Fixture hygiene: what a previous run or a retry left on the account.
|
||||
await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [
|
||||
"DELETE FROM mental_health_assessments WHERE user_id = $1",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const [locale, words] of Object.entries(WHO5_WORDING)) {
|
||||
test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await openInLocale(page, locale);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale);
|
||||
await page
|
||||
.locator('[data-slot="instrument-card"][data-instrument="WHO5"]')
|
||||
.locator('[data-slot="instrument-card-start"]')
|
||||
.click();
|
||||
|
||||
const wizard = page.locator(
|
||||
'[data-slot="mental-health-check-in"][data-instrument="WHO5"]',
|
||||
);
|
||||
const step = (n: number) =>
|
||||
wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`);
|
||||
|
||||
await expect(step(1)).toBeVisible();
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-progress"]'),
|
||||
).toContainText(words.progress);
|
||||
await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText(
|
||||
words.stem,
|
||||
);
|
||||
await expect(
|
||||
step(1).locator('[data-slot="check-in-question"]'),
|
||||
).toHaveText(words.first);
|
||||
await expect(step(1).locator('[role="radio"]')).toHaveText([
|
||||
...words.anchors,
|
||||
]);
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
|
||||
).toHaveCount(words.validated ? 0 : 1);
|
||||
|
||||
// "Meistens" / "A maior parte do tempo" (value 4) on every item.
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
await expect(step(n)).toBeVisible();
|
||||
if (n === 5) {
|
||||
await expect(
|
||||
step(n).locator('[data-slot="check-in-question"]'),
|
||||
).toHaveText(words.last);
|
||||
}
|
||||
await step(n).getByRole("radio", { name: words.anchors[1] }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
wizard.locator('[data-slot="check-in-review"]'),
|
||||
).toBeVisible();
|
||||
const created = page.waitForResponse(
|
||||
(res) =>
|
||||
new URL(res.url()).pathname === "/api/mental-health/assessments" &&
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
await wizard.locator('[data-slot="check-in-submit"]').click();
|
||||
expect((await created).status()).toBe(201);
|
||||
|
||||
// Five items at 4, times WHO-5's multiplier of 4.
|
||||
const result = page.locator(
|
||||
'[data-slot="mental-health-result"][data-instrument="WHO5"]',
|
||||
);
|
||||
await expect(result.locator('[data-slot="result-score"]')).toContainText(
|
||||
"80",
|
||||
);
|
||||
await expect(
|
||||
result.locator('[data-slot="result-wording-note"]'),
|
||||
).toHaveCount(words.validated ? 0 : 1);
|
||||
|
||||
expect(await storedAssessments(page)).toEqual([
|
||||
{
|
||||
instrument: "WHO5",
|
||||
locale,
|
||||
wording: words.validated ? "validated" : "unvalidated_translation",
|
||||
totalScore: 80,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
+66
-32
@@ -1,39 +1,53 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
criticalAccount,
|
||||
criticalStorageState,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
|
||||
/**
|
||||
* Logging a mood through the form, against the real server: the time stays
|
||||
* visible while the details are collapsed, the instant typed into it is the
|
||||
* instant stored, and the entry is in the list afterwards.
|
||||
*
|
||||
* Until v1.46 this spec answered the POST and the list read itself, so the
|
||||
* list was always empty and the POST always succeeded. The account is now the
|
||||
* mood journey's own, one per project (`critical-fixture.ts`), emptied before
|
||||
* the test; the empty state the form opens from is the account's real one.
|
||||
*/
|
||||
|
||||
/** The account's mood entries, as the list route answers them. */
|
||||
async function storedMoods(
|
||||
page: Page,
|
||||
): Promise<Array<{ mood: string; moodLoggedAt: string }>> {
|
||||
const res = await page.request.get("/api/mood-entries");
|
||||
expect(res.status()).toBe(200);
|
||||
const { data } = (await res.json()) as {
|
||||
data: { entries: Array<{ mood: string; moodLoggedAt: string }> };
|
||||
};
|
||||
return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt }));
|
||||
}
|
||||
|
||||
test.describe("mood timestamp capture", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
test.use({ storageState: criticalStorageState("mood") });
|
||||
|
||||
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// Fixture hygiene: what a previous run or a retry left on the account.
|
||||
await resetCriticalAccount(criticalAccount("mood", testInfo), [
|
||||
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
|
||||
"DELETE FROM mood_entries WHERE user_id = $1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
let submitted: Record<string, unknown> | null = null;
|
||||
|
||||
await page.route("**/api/mood-entries*", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
submitted = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mood", { waitUntil: "domcontentloaded" });
|
||||
// The list is mocked empty, so its empty state carries the add action
|
||||
// (the header drops its own while there is nothing to list).
|
||||
// An empty list carries the add action in its empty state (the header
|
||||
// drops its own while there is nothing to list).
|
||||
await page.getByRole("button", { name: "Log your first mood" }).click();
|
||||
await page.getByRole("radio", { name: "Amazing" }).click();
|
||||
|
||||
@@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => {
|
||||
await timeInput.fill("09:45");
|
||||
await timeInput.blur();
|
||||
|
||||
const created = page.waitForResponse(
|
||||
(res) =>
|
||||
new URL(res.url()).pathname === "/api/mood-entries" &&
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
expect((await created).status()).toBe(201);
|
||||
|
||||
await expect.poll(() => submitted).not.toBeNull();
|
||||
expect(submitted).toMatchObject({
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-01-15T08:45:00.000Z",
|
||||
// 09:45 in Berlin in January is 08:45 UTC.
|
||||
expect(await storedMoods(page)).toEqual([
|
||||
{ mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" },
|
||||
]);
|
||||
|
||||
// The list shows it, at either width (the table and the phone cards each
|
||||
// carry the marker; one of them is on screen).
|
||||
const rows = page.locator('[data-slot="mood-rows"]').filter({
|
||||
visible: true,
|
||||
});
|
||||
await expect(rows).toBeVisible();
|
||||
await expect(rows).toContainText("Amazing");
|
||||
await expect(rows).toContainText("09:45");
|
||||
|
||||
// And still after a reload, which reads the list from the server again
|
||||
// rather than from the cache the mutation updated.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(rows).toContainText("Amazing");
|
||||
await expect(rows).toContainText("09:45");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("offline, with the service worker", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
|
||||
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
|
||||
@@ -1,29 +1,69 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
criticalAccount,
|
||||
criticalStorageState,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
|
||||
/**
|
||||
* Lab results from a spreadsheet (#1215), at phone width, against the real
|
||||
* server: the mapping is suggested from the headers, the check reports a
|
||||
* refused row by line and writes nothing, the import writes the rest, and a
|
||||
* second import of the same file only finds duplicates.
|
||||
* Lab results from a spreadsheet (#1215), against the real server: the
|
||||
* mapping is suggested from the headers, the check reports a refused row by
|
||||
* line and writes nothing, the import writes the rest, the imported row is on
|
||||
* the labs page, and a second import of the same file only finds duplicates.
|
||||
*
|
||||
* The account is the labs journey's own, one per project
|
||||
* (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on
|
||||
* the shared account in both projects, so the "only duplicates" verdict of
|
||||
* one project could be the other project's first import.
|
||||
*/
|
||||
|
||||
const ANALYTE = "E2E Marker CSV";
|
||||
|
||||
function csv(): string {
|
||||
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
|
||||
return [
|
||||
"Analyte;Value;Unit;Date",
|
||||
`E2E Marker CSV;5,4;mmol/L;${day}`,
|
||||
`E2E Marker CSV;1.234;mmol/L;${day}`,
|
||||
// A decimal comma, as a German lab export writes it.
|
||||
`${ANALYTE};5,4;mmol/L;${day}`,
|
||||
// Either 1.234 or 1234: refused as ambiguous rather than guessed.
|
||||
`${ANALYTE};1.234;mmol/L;${day}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** The account's lab results, as the list route answers them. */
|
||||
async function storedResults(
|
||||
page: Page,
|
||||
): Promise<Array<{ analyte: string; value: number | null; unit: string }>> {
|
||||
const res = await page.request.get("/api/labs");
|
||||
expect(res.status()).toBe(200);
|
||||
const { data } = (await res.json()) as {
|
||||
data: {
|
||||
results: Array<{ analyte: string; value: number | null; unit: string }>;
|
||||
};
|
||||
};
|
||||
return data.results.map(({ analyte, value, unit }) => ({
|
||||
analyte,
|
||||
value,
|
||||
unit,
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe("record CSV import", () => {
|
||||
test.use({
|
||||
storageState: STORAGE_STATE_PATH,
|
||||
viewport: { width: 390, height: 844 },
|
||||
test.use({ storageState: criticalStorageState("labs") });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// Fixture hygiene: what a previous run or a retry left on the account.
|
||||
await resetCriticalAccount(criticalAccount("labs", testInfo), [
|
||||
"DELETE FROM lab_results WHERE user_id = $1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("checks, imports and then only finds duplicates", async ({ page }) => {
|
||||
test("checks, imports, lists the result and then only finds duplicates @critical", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/export");
|
||||
await page.locator('[data-slot="record-import-open-labs"]').click();
|
||||
const sheet = page.locator('[data-slot="record-import-sheet"]');
|
||||
@@ -48,19 +88,42 @@ test.describe("record CSV import", () => {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-import-error-row"]'),
|
||||
).toHaveCount(1);
|
||||
// The check is a dry run.
|
||||
expect(await storedResults(page)).toEqual([]);
|
||||
|
||||
await page.locator('[data-slot="record-import-run"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="record-import-done"]'),
|
||||
).toBeVisible();
|
||||
expect(await storedResults(page)).toEqual([
|
||||
{ analyte: ANALYTE, value: 5.4, unit: "mmol/L" },
|
||||
]);
|
||||
|
||||
// Second run of the same file.
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator('[data-slot="record-import-open-labs"]').click();
|
||||
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
|
||||
await page.locator('[data-slot="record-import-check"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="record-import-run"]'),
|
||||
).toBeDisabled();
|
||||
await test.step("the result is on the labs page", async () => {
|
||||
await page.goto("/labs");
|
||||
const analyte = page
|
||||
.locator('[data-slot="lab-list-analyte"]')
|
||||
.filter({ hasText: ANALYTE })
|
||||
.filter({ visible: true });
|
||||
await expect(analyte).toHaveCount(1);
|
||||
// The cell the name heads carries the latest reading under it.
|
||||
await expect(analyte.locator("xpath=../..")).toContainText("5.4");
|
||||
});
|
||||
|
||||
await test.step("a second run of the same file", async () => {
|
||||
await page.goto("/settings/export");
|
||||
await page.locator('[data-slot="record-import-open-labs"]').click();
|
||||
await page
|
||||
.locator('[data-slot="record-import-file"]')
|
||||
.setInputFiles(file);
|
||||
await page.locator('[data-slot="record-import-check"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="record-import-checked"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="record-import-run"]'),
|
||||
).toBeDisabled();
|
||||
expect(await storedResults(page)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle";
|
||||
* v1.25.11 settings fix): the scroll viewport must never extend far past
|
||||
* the last content element. The rule the guard pins is structural — only
|
||||
* the AuthShell owns the scroll height and the bottom padding
|
||||
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
|
||||
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
|
||||
* `<main>`); a page/section must never add its own viewport-height
|
||||
* reserve or bottom gutter, because every such nested reserve stacks on
|
||||
* the shell's own budget and reopens the dark-band-below-the-last-card
|
||||
@@ -45,7 +45,7 @@ const ROUTES = [
|
||||
] as const;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
|
||||
{ name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24
|
||||
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
criticalAccount,
|
||||
criticalStorageState,
|
||||
resetCriticalAccount,
|
||||
} from "./setup/critical-fixture";
|
||||
|
||||
/**
|
||||
* A settings change is the server's, not the tab's.
|
||||
*
|
||||
* The time-format choice is changed through the account settings, then read
|
||||
* back three ways that a client-side cache could not fake: after a reload, in
|
||||
* a second browser context that shares the session but none of the first
|
||||
* one's storage, and on `/api/auth/me`, which every client renders from.
|
||||
*
|
||||
* The account is the settings journey's own, one per project
|
||||
* (`critical-fixture.ts`), and is put back to the default first.
|
||||
*/
|
||||
|
||||
async function openTimeFormat(page: Page) {
|
||||
await page.goto("/settings/account");
|
||||
const select = page.getByTestId("settings-time-format-select");
|
||||
await expect(select).toBeVisible({ timeout: 30_000 });
|
||||
// The control is disabled until the account payload has landed.
|
||||
await expect(select).toBeEnabled();
|
||||
return select;
|
||||
}
|
||||
|
||||
async function publishedTimeFormat(page: Page): Promise<string> {
|
||||
const res = await page.request.get("/api/auth/me");
|
||||
expect(res.status()).toBe(200);
|
||||
return ((await res.json()) as { data: { timeFormat: string } }).data
|
||||
.timeFormat;
|
||||
}
|
||||
|
||||
test.describe("settings persistence", () => {
|
||||
test.use({ storageState: criticalStorageState("settings") });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
await resetCriticalAccount(criticalAccount("settings", testInfo), [
|
||||
"UPDATE users SET time_format = 'AUTO' WHERE id = $1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a time-format change survives a reload and reaches a second browser @critical", async ({
|
||||
page,
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
const select = await openTimeFormat(page);
|
||||
await expect(select).toHaveValue("AUTO");
|
||||
|
||||
const saved = page.waitForResponse(
|
||||
(res) =>
|
||||
new URL(res.url()).pathname === "/api/user/profile" &&
|
||||
res.request().method() === "PATCH",
|
||||
);
|
||||
await select.selectOption("H12");
|
||||
expect((await saved).status()).toBe(200);
|
||||
expect(await publishedTimeFormat(page)).toBe("H12");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("settings-time-format-select")).toHaveValue(
|
||||
"H12",
|
||||
);
|
||||
|
||||
// Same session cookie, nothing else: no local storage, no IndexedDB
|
||||
// query snapshot, no in-memory cache.
|
||||
const other = await browser.newContext({
|
||||
storageState: criticalAccount("settings", testInfo).storageStatePath,
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
try {
|
||||
const second = await other.newPage();
|
||||
await expect(await openTimeFormat(second)).toHaveValue("H12");
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Accounts for the `@critical` journeys: one per journey AND per project.
|
||||
*
|
||||
* The `@critical` journeys run against the real server with no `/api` stubs
|
||||
* (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so
|
||||
* every verdict they read is a statement about a real record. Two things would
|
||||
* make those verdicts somebody else's:
|
||||
*
|
||||
* - a sibling spec writing into the same account. The shared fixture account
|
||||
* is read and written by sixty files, so "the dashboard tile shows the
|
||||
* weight I just entered" is only true of an account nobody else writes;
|
||||
* - the same journey running in the other project. Every other journey that
|
||||
* owns its account answers that by running in ONE project (the long list in
|
||||
* `playwright.config.ts`). These journeys are the release gate and are
|
||||
* meant to hold at both widths, so instead of dropping a project each
|
||||
* project gets its own account: `chromium-desktop` and `chromium-mobile`
|
||||
* run the same file in parallel and never meet.
|
||||
*
|
||||
* The accounts are seeded and signed in by `global-setup.ts`, like every
|
||||
* other fixture account; this module only names them and resets what the
|
||||
* journeys write. It talks to Postgres through `pg` for the reason
|
||||
* `global-setup.ts` gives (Playwright's loader cannot load the generated
|
||||
* Prisma client).
|
||||
*/
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
/** The journeys that own an account pair. */
|
||||
export const CRITICAL_JOURNEYS = [
|
||||
"auth",
|
||||
"measurement",
|
||||
"mood",
|
||||
"labs",
|
||||
"wellbeing",
|
||||
"settings",
|
||||
// The fresh account a full backup is imported into. The backup journey
|
||||
// runs in the desktop project only (`playwright.config.ts` says why), so
|
||||
// only the desktop account of this pair is ever written.
|
||||
"restore",
|
||||
] as const;
|
||||
|
||||
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
|
||||
|
||||
/**
|
||||
* Journeys that sign in through the form in every test and so get no jar.
|
||||
* The sign-in journey ends the sessions it opens; a jar captured once in
|
||||
* global setup would be dead for a retry, and a retry has to start from the
|
||||
* same place as the first attempt.
|
||||
*/
|
||||
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
|
||||
"auth",
|
||||
]);
|
||||
|
||||
/** The projects the journeys run in; each gets its own account per journey. */
|
||||
export const CRITICAL_PROJECTS = {
|
||||
"chromium-desktop": "d",
|
||||
"chromium-mobile": "m",
|
||||
} as const;
|
||||
|
||||
type CriticalProject = keyof typeof CRITICAL_PROJECTS;
|
||||
|
||||
export interface CriticalAccount {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
storageStatePath: string;
|
||||
}
|
||||
|
||||
function accountFor(
|
||||
journey: CriticalJourney,
|
||||
suffix: (typeof CRITICAL_PROJECTS)[CriticalProject],
|
||||
): CriticalAccount {
|
||||
const username = `e2e-critical-${journey}-${suffix}`;
|
||||
return {
|
||||
username,
|
||||
email: `${username}@healthlog.test`,
|
||||
// Fixture credential for a throwaway test database, like every other
|
||||
// account in `global-setup.ts`. Long and mixed so it would also clear the
|
||||
// registration strength gate.
|
||||
password: `Cq7!${journey}-${suffix}-Wz4pLx`,
|
||||
storageStatePath: resolve(
|
||||
process.cwd(),
|
||||
`e2e/setup/storageStateCritical-${journey}-${suffix}.json`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every critical account, for `global-setup.ts` to seed and sign in. */
|
||||
export function allCriticalAccounts(): Array<
|
||||
CriticalAccount & { journey: CriticalJourney }
|
||||
> {
|
||||
return CRITICAL_JOURNEYS.flatMap((journey) =>
|
||||
Object.values(CRITICAL_PROJECTS).map((suffix) => ({
|
||||
journey,
|
||||
...accountFor(journey, suffix),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a journey uses in the project the test is running in. A project
|
||||
* outside the map has no account on purpose: a critical journey that grows a
|
||||
* third project has to say which account it reads.
|
||||
*/
|
||||
export function criticalAccount(
|
||||
journey: CriticalJourney,
|
||||
testInfo: TestInfo,
|
||||
): CriticalAccount {
|
||||
const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject];
|
||||
if (!suffix) {
|
||||
throw new Error(
|
||||
`[critical-fixture] no ${journey} account for project ${testInfo.project.name}`,
|
||||
);
|
||||
}
|
||||
return accountFor(journey, suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `test.use({ storageState })` value that picks the jar of the running
|
||||
* project's account.
|
||||
*/
|
||||
export function criticalStorageState(journey: CriticalJourney) {
|
||||
return async (
|
||||
{}: object,
|
||||
use: (path: string) => Promise<void>,
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> => {
|
||||
await use(criticalAccount(journey, testInfo).storageStatePath);
|
||||
};
|
||||
}
|
||||
|
||||
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
return await fn(pool);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run statements against one critical account, with `$1` bound to its id.
|
||||
* Fixture hygiene only: what a previous run or a retry left behind. Nothing a
|
||||
* journey asserts is written here.
|
||||
*/
|
||||
export async function resetCriticalAccount(
|
||||
account: CriticalAccount,
|
||||
statements: readonly string[],
|
||||
): Promise<void> {
|
||||
await withPool(async (pool) => {
|
||||
const res = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[account.username],
|
||||
);
|
||||
const id = res.rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`[critical-fixture] ${account.username} not seeded — global-setup must run first`,
|
||||
);
|
||||
}
|
||||
for (const sql of statements) await pool.query(sql, [id]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the login buckets. The ceiling is five attempts per IP per quarter
|
||||
* hour, the suite shares one IP, and global setup alone signs in more than
|
||||
* twenty times; a journey that signs in through the form would otherwise meet
|
||||
* the fixture's 429 rather than the product. Only the auth surfaces' buckets
|
||||
* are touched, exactly as global setup does between its own logins.
|
||||
*/
|
||||
export async function clearLoginBuckets(): Promise<void> {
|
||||
await withPool(async (pool) => {
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a critical account and create it again, empty, under the same id,
|
||||
* username and password. The backup journey restores a person's file into
|
||||
* the account the way the runbook brings one back on a new host, where the
|
||||
* file names its owner by id; a browser cannot delete and recreate a row
|
||||
* under a chosen id, so this is the fixture's act, not the journey's.
|
||||
*/
|
||||
export async function recreateCriticalAccount(
|
||||
account: CriticalAccount,
|
||||
): Promise<void> {
|
||||
await withPool(async (pool) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const res = await client.query<{
|
||||
id: string;
|
||||
password_hash: string;
|
||||
module_preferences_json: unknown;
|
||||
}>(
|
||||
`SELECT id, password_hash, module_preferences_json
|
||||
FROM users WHERE username = $1`,
|
||||
[account.username],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) {
|
||||
throw new Error(`[critical-fixture] ${account.username} not seeded`);
|
||||
}
|
||||
await client.query("DELETE FROM users WHERE id = $1", [row.id]);
|
||||
await client.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`,
|
||||
[
|
||||
row.id,
|
||||
account.username,
|
||||
account.email,
|
||||
row.password_hash,
|
||||
JSON.stringify(row.module_preferences_json),
|
||||
],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** How many measurements and mood entries a critical account holds. */
|
||||
export async function accountRowCounts(
|
||||
account: CriticalAccount,
|
||||
): Promise<{ measurements: number; moodEntries: number }> {
|
||||
return withPool(async (pool) => {
|
||||
const res = await pool.query<{ measurements: string; mood: string }>(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM measurements m
|
||||
JOIN users u ON u.id = m.user_id WHERE u.username = $1)
|
||||
AS measurements,
|
||||
(SELECT count(*) FROM mood_entries e
|
||||
JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`,
|
||||
[account.username],
|
||||
);
|
||||
return {
|
||||
measurements: Number(res.rows[0]?.measurements ?? 0),
|
||||
moodEntries: Number(res.rows[0]?.mood ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
|
||||
import { seedDayDoorsRecord } from "./day-doors-fixture";
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
|
||||
@@ -250,6 +251,41 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateTimeline.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The vault specs' own accounts (`documents.spec.ts`, and the documents
|
||||
* journeys of `v142-qol.spec.ts`).
|
||||
*
|
||||
* The vault's layout (cards or a list, months stacked or side by side) is a
|
||||
* per-account preference. On the shared account the layout journey turned it
|
||||
* while the month-grid tests read the default, so either could fail depending
|
||||
* on which ran first. Each file seeds its own vault into its own account
|
||||
* (`ensureVaultFixture(username)`).
|
||||
*/
|
||||
export const E2E_VAULT = {
|
||||
email: "e2e-vault@healthlog.test",
|
||||
username: "e2e-vault",
|
||||
password: "Vt4!Hq8mZr2wKx6N",
|
||||
// The upload-cap test sets the instance's cap through the admin console.
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
export const VAULT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateVault.json",
|
||||
);
|
||||
|
||||
export const E2E_VAULT_LAYOUT = {
|
||||
email: "e2e-vault-layout@healthlog.test",
|
||||
username: "e2e-vault-layout",
|
||||
password: "Vl7!Bn3qXw9pRt5M",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateVaultLayout.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The day-doors journey's own account (`day-doors.spec.ts`).
|
||||
*
|
||||
@@ -990,6 +1026,32 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns
|
||||
// the documents module on and seeds the documents.
|
||||
for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, $6, $5, $5, $5, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
[
|
||||
cuid(),
|
||||
account.username,
|
||||
account.email,
|
||||
await hashPassword(account.password),
|
||||
now,
|
||||
account.role,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// The day-doors journey's account. Every module the journey reaches is
|
||||
// on; its record is written after the login capture.
|
||||
await pool.query(
|
||||
@@ -1364,6 +1426,28 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// surfaces' own buckets are cleared; nothing else in the table is touched.
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
|
||||
// Every other bucket the suite can fill, for the same reason. A bucket is
|
||||
// keyed on the account it meters (`<surface>:<id>`, sometimes with a
|
||||
// further segment) or, on an anonymous surface, on the caller's address,
|
||||
// and several of them outlive a run by up to an hour: the check-in
|
||||
// ceiling, the per-account write bucket, the export and import buckets.
|
||||
// A second run on the same database then met the first run's counts.
|
||||
// Cleared are the buckets of the fixture accounts (every one of them is
|
||||
// an `@healthlog.test` address or an `e2e-` record) and those of the
|
||||
// loopback addresses the suite calls from; a developer's own accounts
|
||||
// and their buckets are left alone.
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits r
|
||||
USING users u
|
||||
WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%')
|
||||
AND position(u.id IN r.key) > 0`,
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1'
|
||||
OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`,
|
||||
);
|
||||
|
||||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||||
// re-run cannot accumulate duplicates and so an edited constant takes
|
||||
// effect rather than sitting beside the old value.
|
||||
@@ -1539,6 +1623,49 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
|
||||
);
|
||||
|
||||
// ── the @critical journeys' accounts ─────────────────────────────────
|
||||
//
|
||||
// One per journey and per project; `critical-fixture.ts` says why. Every
|
||||
// module a journey reaches is switched on (wellbeing and labs are opt-in),
|
||||
// and the passkeys the sign-in journey registers go with the row's reset,
|
||||
// so a re-run registers into an empty list rather than beside the last
|
||||
// run's credential.
|
||||
for (const account of allCriticalAccounts()) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json,
|
||||
totp_confirmed_at = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
account.username,
|
||||
account.email,
|
||||
await hashPassword(account.password),
|
||||
now,
|
||||
JSON.stringify({
|
||||
mood: true,
|
||||
labs: true,
|
||||
mentalHealth: true,
|
||||
medications: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM passkeys
|
||||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||||
[account.username],
|
||||
);
|
||||
}
|
||||
|
||||
// Console (instead of structured logging) is intentional here —
|
||||
// global-setup runs outside the app's logging context, and the
|
||||
// line is useful when debugging a CI failure where the seed didn't
|
||||
@@ -1646,6 +1773,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The timeline journey's jar — see `E2E_TIMELINE`.
|
||||
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
|
||||
|
||||
// The vault specs' jars — see `E2E_VAULT`.
|
||||
await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH);
|
||||
|
||||
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
|
||||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
@@ -1708,6 +1839,13 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
[E2E_SETUP_CHILD.username, now],
|
||||
);
|
||||
|
||||
// The @critical journeys' jars. The sign-in journey signs in through the
|
||||
// form in every test, so its account gets none.
|
||||
for (const account of allCriticalAccounts()) {
|
||||
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
|
||||
await capture(account, account.storageStatePath);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ export async function resetSetupFlow(username: string): Promise<void> {
|
||||
WHERE key IN (
|
||||
'onboarding-answers:' || $1,
|
||||
'onboarding-restart:' || $1,
|
||||
'managed-profile:create:' || $1
|
||||
'managed-profile:create:' || $1,
|
||||
'record-write:' || $1
|
||||
)`,
|
||||
[id],
|
||||
);
|
||||
|
||||
+60
-16
@@ -188,6 +188,39 @@ function jpegWithGps(marker: string): Buffer {
|
||||
|
||||
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
|
||||
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
|
||||
|
||||
/**
|
||||
* The vault corpus's ids for one account. The shared account keeps the ids
|
||||
* above; an account of its own gets the same corpus under ids of the same
|
||||
* length with its own five-letter tag in place of `vault`, so two accounts'
|
||||
* vaults never claim one row. Only `ensureVaultFixture` and
|
||||
* `seedNamespaceDocs` take an account.
|
||||
*/
|
||||
const VAULT_TAGS: Record<string, string> = {
|
||||
"e2e-vault": "vspec",
|
||||
"e2e-vault-layout": "vqolx",
|
||||
};
|
||||
|
||||
function vaultTag(username: string): string {
|
||||
if (username === E2E_USER.username) return "vault";
|
||||
const tag = VAULT_TAGS[username];
|
||||
if (!tag) throw new Error(`[vault-fixture] no id tag for ${username}`);
|
||||
return tag;
|
||||
}
|
||||
|
||||
function inVault(id: string, username: string): string {
|
||||
return id.replace(/^e2evault/, `e2e${vaultTag(username)}`);
|
||||
}
|
||||
|
||||
export function vaultFixtureIds(username: string): {
|
||||
knieEpisodeId: string;
|
||||
mrtDocId: string;
|
||||
} {
|
||||
return {
|
||||
knieEpisodeId: inVault(KNIE_EPISODE_ID, username),
|
||||
mrtDocId: inVault(MRT_DOC_ID, username),
|
||||
};
|
||||
}
|
||||
/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are
|
||||
* bland so an applied suggestion is visibly different. */
|
||||
export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
|
||||
@@ -268,10 +301,10 @@ async function withVaultSeedLock(
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserId(pool: pg.Pool): Promise<string> {
|
||||
async function getUserId(pool: pg.Pool, username: string): Promise<string> {
|
||||
const res = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[E2E_USER.username],
|
||||
[username],
|
||||
);
|
||||
const id = res.rows[0]?.id;
|
||||
if (!id) {
|
||||
@@ -344,7 +377,7 @@ export async function resetEncounters(): Promise<void> {
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
await pool.query(
|
||||
"DELETE FROM encounter_document_links WHERE user_id = $1",
|
||||
[userId],
|
||||
@@ -360,13 +393,15 @@ export async function resetEncounters(): Promise<void> {
|
||||
* corpus. Safe to call from every spec's beforeAll — every statement
|
||||
* upserts.
|
||||
*/
|
||||
export async function ensureVaultFixture(): Promise<void> {
|
||||
export async function ensureVaultFixture(
|
||||
username: string = E2E_USER.username,
|
||||
): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
await withVaultSeedLock(pool, async () => {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, username);
|
||||
|
||||
// Opt-in module: merge, never overwrite other module preferences.
|
||||
await pool.query(
|
||||
@@ -385,7 +420,7 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
|
||||
[KNIE_EPISODE_ID, userId],
|
||||
[inVault(KNIE_EPISODE_ID, username), userId],
|
||||
);
|
||||
|
||||
// 60 background documents spread over 36 months (newest ~current).
|
||||
@@ -393,7 +428,10 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const monthsBack = Math.floor((i * 36) / 60);
|
||||
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
|
||||
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
|
||||
const id = inVault(
|
||||
`e2evaultdoc${String(i).padStart(12, "0")}`,
|
||||
username,
|
||||
);
|
||||
docs.push({
|
||||
id,
|
||||
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
|
||||
@@ -406,17 +444,17 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
}
|
||||
// The MRT report + two adjacent images, same date, all linked to Knie.
|
||||
docs.push({
|
||||
id: MRT_DOC_ID,
|
||||
id: inVault(MRT_DOC_ID, username),
|
||||
kind: "IMAGING",
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie.pdf",
|
||||
mimeType: "application/pdf",
|
||||
documentDate: MRT_DOC_DATE,
|
||||
bytes: tinyPdf(MRT_DOC_ID),
|
||||
bytes: tinyPdf(inVault(MRT_DOC_ID, username)),
|
||||
});
|
||||
for (const n of [1, 2]) {
|
||||
docs.push({
|
||||
id: `e2evaultimg000000000000${n}`,
|
||||
id: inVault(`e2evaultimg000000000000${n}`, username),
|
||||
kind: "IMAGING",
|
||||
title: `MRT Aufnahme ${n}`,
|
||||
filename: `mrt-aufnahme-${n}.png`,
|
||||
@@ -432,14 +470,19 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
MRT_DOC_ID,
|
||||
"e2evaultimg0000000000001",
|
||||
"e2evaultimg0000000000002",
|
||||
];
|
||||
].map((id) => inVault(id, username));
|
||||
for (const [i, docId] of linked.entries()) {
|
||||
await pool.query(
|
||||
`INSERT INTO document_condition_links
|
||||
(id, document_id, episode_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (document_id, episode_id) DO NOTHING`,
|
||||
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
|
||||
[
|
||||
inVault(`e2evaultlink00000000000${i + 1}`, username),
|
||||
docId,
|
||||
inVault(KNIE_EPISODE_ID, username),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -462,7 +505,7 @@ export async function ensureVaultAiFixture(): Promise<void> {
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
await withVaultSeedLock(pool, async () => {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
|
||||
await upsertDocs(pool, userId, [
|
||||
{
|
||||
@@ -522,12 +565,13 @@ export async function ensureVaultAiFixture(): Promise<void> {
|
||||
export async function seedNamespaceDocs(
|
||||
prefix: string,
|
||||
count: number,
|
||||
username: string = E2E_USER.username,
|
||||
): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, username);
|
||||
const docs: DocRow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `e2e${prefix}${String(i).padStart(8, "0")}`;
|
||||
@@ -569,7 +613,7 @@ export async function ensureProcedureFixture(): Promise<void> {
|
||||
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
const procedures = [
|
||||
{
|
||||
id: PROCEDURE_KNEE_ID,
|
||||
@@ -636,7 +680,7 @@ export async function ensureShareDocFixture(): Promise<void> {
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET module_preferences_json =
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The app shell on a tablet, under a finger, at every iPad size in both
|
||||
* orientations: the rail reaches every destination, `<main>` scrolls to its
|
||||
* last line, and closing a sheet or a dialog hands the scroll back.
|
||||
*
|
||||
* Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job)
|
||||
* and `chromium-tablet` (an Android tablet, in the Chromium shards). Both
|
||||
* report `pointer: coarse`, so the rail keeps its 44 px rows and its list
|
||||
* overflows in landscape, which is the case that broke.
|
||||
*
|
||||
* What a browser under test cannot show is the iPadOS toolbar moving the
|
||||
* visible height away from `100dvh`; `shell-viewport.test.ts` holds the
|
||||
* decisions that take that disagreement out, and this file measures that the
|
||||
* shell they produce fills the window exactly and leaves the document
|
||||
* nothing to scroll or bounce.
|
||||
*
|
||||
* Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`,
|
||||
* `sidebar-footer`, `main-content-wrapper`, `bottom-nav`,
|
||||
* `bottom-nav-more` (test id), `command-palette-trigger`,
|
||||
* `command-palette-input`, `day-panel`.
|
||||
*/
|
||||
|
||||
/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */
|
||||
const VIEWPORTS = [
|
||||
{ name: "iPad mini portrait", width: 744, height: 1133 },
|
||||
{ name: "iPad mini landscape", width: 1133, height: 744 },
|
||||
{ name: "iPad mini landscape, Safari bars", width: 1133, height: 670 },
|
||||
{ name: "iPad landscape, Safari bars", width: 1000, height: 690 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
|
||||
{ name: "iPad Pro 11 landscape", width: 1194, height: 834 },
|
||||
{ name: "iPad Pro 13 portrait", width: 1024, height: 1366 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
{ name: "Split View half, landscape", width: 590, height: 820 },
|
||||
{ name: "Slide Over", width: 375, height: 1024 },
|
||||
{ name: "Android tablet landscape", width: 1138, height: 712 },
|
||||
] as const;
|
||||
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
async function openDashboard(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('#main-content [data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
// The module-gated destinations join the rail once `/api/auth/me` lands.
|
||||
await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** The shell fills the window exactly and the document has nothing to scroll. */
|
||||
async function expectShellPinned(page: Page) {
|
||||
const m = await page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".shell-safe-area")!;
|
||||
const rect = shell.getBoundingClientRect();
|
||||
const root = document.scrollingElement!;
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
innerHeight,
|
||||
innerWidth,
|
||||
docScroll: root.scrollHeight - root.clientHeight,
|
||||
overflowY: html.overflowY,
|
||||
overscroll: html.overscrollBehaviorY,
|
||||
};
|
||||
});
|
||||
expect(m.top).toBe(0);
|
||||
expect(m.left).toBe(0);
|
||||
expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5);
|
||||
expect(m.docScroll).toBeLessThanOrEqual(0);
|
||||
expect(m.overflowY).toBe("hidden");
|
||||
// No rubber band on the document: on iOS it held the next swipe.
|
||||
expect(m.overscroll).toBe("none");
|
||||
}
|
||||
|
||||
function railNav(page: Page): Locator {
|
||||
return page.locator('aside [data-slot="sidebar-nav-region"] > nav');
|
||||
}
|
||||
|
||||
/** Scrolls `<main>` to its end, waiting out content that grows on the way. */
|
||||
async function scrollMainToEnd(page: Page) {
|
||||
let last = -1;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const height = await page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return main.scrollHeight;
|
||||
});
|
||||
if (height === last) return;
|
||||
last = height;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the page's lowest content ends, and what covers the window's bottom edge. */
|
||||
async function measureMainEnd(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
const wrapper = main.querySelector<HTMLElement>(
|
||||
'[data-slot="main-content-wrapper"]',
|
||||
)!;
|
||||
let contentBottom = 0;
|
||||
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
let p = el.parentElement;
|
||||
let nested = false;
|
||||
while (p && p !== main) {
|
||||
const ps = getComputedStyle(p);
|
||||
if (
|
||||
/(auto|scroll|hidden)/.test(ps.overflowY) &&
|
||||
p.scrollHeight > p.clientHeight + 1
|
||||
) {
|
||||
nested = true;
|
||||
break;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (nested) continue;
|
||||
contentBottom = Math.max(contentBottom, rect.bottom);
|
||||
}
|
||||
const visible = (el: Element | null): el is HTMLElement =>
|
||||
!!el &&
|
||||
getComputedStyle(el).display !== "none" &&
|
||||
el.getBoundingClientRect().height > 0;
|
||||
const bar = document.querySelector('[data-slot="bottom-nav"]');
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
return {
|
||||
atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1,
|
||||
scrolls: main.scrollHeight > main.clientHeight,
|
||||
contentBottom,
|
||||
mainBottom: mainRect.bottom,
|
||||
barTop: visible(bar) ? bar.getBoundingClientRect().top : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
});
|
||||
|
||||
test("every rail destination scrolls into view and takes a tap", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await expectShellPinned(page);
|
||||
|
||||
const aside = page.locator("aside");
|
||||
const railShown = vp.width >= 768;
|
||||
if (!railShown) {
|
||||
// Split View and Slide Over get the phone shell: the bar, no rail.
|
||||
await expect(aside).toBeHidden();
|
||||
await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(aside).toBeVisible();
|
||||
|
||||
// Nothing in the rail is clipped by the rail: the logo band, the list
|
||||
// region, the pinned footer and the account row all sit inside it, in
|
||||
// that order, and the list region alone gives up height.
|
||||
const parts = await page.evaluate(() => {
|
||||
const asideEl = document.querySelector("aside")!;
|
||||
const box = asideEl.getBoundingClientRect();
|
||||
const pick = (sel: string) =>
|
||||
asideEl.querySelector(sel)!.getBoundingClientRect();
|
||||
const column = asideEl.querySelector('[data-slot="sidebar-column"]')!;
|
||||
const user = column.lastElementChild!.getBoundingClientRect();
|
||||
return {
|
||||
clipped: asideEl.scrollHeight - asideEl.clientHeight,
|
||||
asideTop: box.top,
|
||||
asideBottom: box.bottom,
|
||||
header: pick('[data-slot="sidebar-header"]'),
|
||||
region: pick('[data-slot="sidebar-nav-region"]'),
|
||||
footer: pick('[data-slot="sidebar-footer"]'),
|
||||
user,
|
||||
overscroll: getComputedStyle(
|
||||
asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!,
|
||||
).overscrollBehaviorY,
|
||||
};
|
||||
});
|
||||
expect(parts.clipped).toBeLessThanOrEqual(0);
|
||||
expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5);
|
||||
expect(parts.region.top).toBeGreaterThanOrEqual(
|
||||
parts.header.bottom - 0.5,
|
||||
);
|
||||
expect(parts.footer.top).toBeGreaterThanOrEqual(
|
||||
parts.region.bottom - 0.5,
|
||||
);
|
||||
expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5);
|
||||
expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5);
|
||||
expect(parts.overscroll).toBe("contain");
|
||||
await expect(
|
||||
aside.locator('[data-slot="sidebar-collapse-toggle"]'),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(
|
||||
aside.locator('[data-slot="nav-settings-link"]'),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
|
||||
// Every destination: scrolled into the list's view, whole, on top
|
||||
// (not under the cue, the footer or anything else), and actionable.
|
||||
const nav = railNav(page);
|
||||
const links = nav.locator("a[href]");
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThanOrEqual(10);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const link = links.nth(i);
|
||||
await link.scrollIntoViewIfNeeded();
|
||||
await expect
|
||||
.poll(() =>
|
||||
link.evaluate((el) => {
|
||||
const navEl = el.closest("nav")!;
|
||||
const r = el.getBoundingClientRect();
|
||||
const n = navEl.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(
|
||||
r.left + r.width / 2,
|
||||
r.top + r.height / 2,
|
||||
);
|
||||
return (
|
||||
r.top >= n.top - 0.5 &&
|
||||
r.bottom <= n.bottom + 0.5 &&
|
||||
!!hit &&
|
||||
el.contains(hit)
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await link.click({ trial: true });
|
||||
}
|
||||
|
||||
// A list taller than its region shows the touch cue at the edge that
|
||||
// has more, and a tap on it is a way there.
|
||||
const overflows = await nav.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight + 1,
|
||||
);
|
||||
const down = page.locator(
|
||||
'[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]',
|
||||
);
|
||||
const up = page.locator(
|
||||
'[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]',
|
||||
);
|
||||
await nav.evaluate((el) => (el.scrollTop = 0));
|
||||
if (overflows) {
|
||||
await expect(down).toBeVisible();
|
||||
await expect(up).toHaveCount(0);
|
||||
await down.tap();
|
||||
await expect
|
||||
.poll(() => nav.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(up).toBeVisible();
|
||||
await nav.evaluate((el) => (el.scrollTop = el.scrollHeight));
|
||||
await expect(down).toHaveCount(0);
|
||||
} else {
|
||||
await expect(down).toHaveCount(0);
|
||||
await expect(up).toHaveCount(0);
|
||||
}
|
||||
|
||||
// And the last destination is a real tap away.
|
||||
const lastLink = links.nth(count - 1);
|
||||
const href = await lastLink.getAttribute("href");
|
||||
await lastLink.scrollIntoViewIfNeeded();
|
||||
await lastLink.tap();
|
||||
await expect(page).toHaveURL(new RegExp(`${href}$`));
|
||||
});
|
||||
|
||||
// The Coach button's clearance is held by `shell-viewport.test.ts`: the
|
||||
// button renders only with an AI provider, which the suite's accounts
|
||||
// do not have, so a check here would never run.
|
||||
test("main scrolls to its last line, clear of the bottom bar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await scrollMainToEnd(page);
|
||||
const end = await measureMainEnd(page);
|
||||
expect(end.scrolls).toBe(true);
|
||||
expect(end.atEnd).toBe(true);
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5);
|
||||
if (end.barTop !== null) {
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
|
||||
}
|
||||
await expectShellPinned(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */
|
||||
async function expectPageScrollsAgain(page: Page, browserName: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
locked: document.body.hasAttribute("data-scroll-locked"),
|
||||
pointer: getComputedStyle(document.body).pointerEvents,
|
||||
})),
|
||||
)
|
||||
.toEqual({ locked: false, pointer: "auto" });
|
||||
const before = await page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
main.scrollTop = 0;
|
||||
const r = main.getBoundingClientRect();
|
||||
const x = r.left + r.width / 2;
|
||||
const y = r.top + r.height / 2;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const wheel = new WheelEvent("wheel", {
|
||||
deltaY: 120,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
});
|
||||
hit?.dispatchEvent(wheel);
|
||||
return {
|
||||
onMain: !!hit && main.contains(hit),
|
||||
blocked: wheel.defaultPrevented,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
});
|
||||
// Nothing invisible lies over `<main>`, and no scroll lock swallows the
|
||||
// wheel (react-remove-scroll cancels it outside its own layer).
|
||||
expect(before.onMain).toBe(true);
|
||||
expect(before.blocked).toBe(false);
|
||||
// A real wheel where the engine has one. Mobile WebKit under Playwright
|
||||
// has none (and no native touch scroll), so there the two checks above
|
||||
// are the proof: nothing covers `<main>` and nothing cancels its events.
|
||||
if (browserName === "webkit") return;
|
||||
await page.mouse.move(before.x, before.y);
|
||||
await page.mouse.wheel(0, 600);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.getElementById("main-content")!.scrollTop),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const MODAL_VIEWPORTS = [
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of MODAL_VIEWPORTS) {
|
||||
test.describe(`${vp.name}: scroll after a modal`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
});
|
||||
|
||||
test("the page scrolls after the command palette dialog closes", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await page.locator('[data-slot="command-palette-trigger"]:visible').tap();
|
||||
const input = page.locator('[data-slot="command-palette-input"]');
|
||||
await expect(input).toBeVisible();
|
||||
// The scroll lock makes the body `position: relative`; the shell
|
||||
// behind the dialog keeps the window's box all the same.
|
||||
await expectShellPinned(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toBeHidden();
|
||||
await expectPageScrollsAgain(page, browserName);
|
||||
});
|
||||
|
||||
test("the page scrolls after a sheet closes", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
if (vp.width < 768) {
|
||||
// The phone shell's More sheet: the draggable bottom sheet.
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectShellPinned(page);
|
||||
await sheet.locator('[data-slot="sheet-close-button"]').tap();
|
||||
await expect(sheet).toBeHidden();
|
||||
} else {
|
||||
// The day, a sheet from the right below 1280 px.
|
||||
await mockDay(page);
|
||||
const day = new Date(Date.now() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" });
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expectShellPinned(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
await expectPageScrollsAgain(page, browserName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a vertical swipe that starts on a tab strip is the page's", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1180, height: 820 });
|
||||
await page.goto("/checkups", { waitUntil: "domcontentloaded" });
|
||||
const strip = page.locator('#main-content [data-slot="tabs-list"]').first();
|
||||
await expect(strip).toBeVisible();
|
||||
// `pan-x` here refused the vertical pan, so the page did not move.
|
||||
const touchAction = await strip.evaluate(
|
||||
(el) => getComputedStyle(el).touchAction,
|
||||
);
|
||||
expect(touchAction).toBe("auto");
|
||||
});
|
||||
|
||||
/**
|
||||
* An iPad without a home button reports a bottom inset of about 20 px, which
|
||||
* no browser under test does. The shell reads the inset through
|
||||
* `--safe-area-bottom` (globals.css), so setting the property is the same
|
||||
* inset to every element that owns the bottom edge.
|
||||
*/
|
||||
const HOME_INDICATOR = 20;
|
||||
|
||||
async function setBottomInset(page: Page, px: number) {
|
||||
await page.addStyleTag({
|
||||
content: `:root { --safe-area-bottom: ${px}px !important; }`,
|
||||
});
|
||||
}
|
||||
|
||||
/** What paints the window's last row of pixels, sampled across its width. */
|
||||
async function bottomRowOwners(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const y = innerHeight - 1;
|
||||
const owners: string[] = [];
|
||||
// 8 px in lands on the rail at its narrowest (64 px); the rest spread
|
||||
// across the content column.
|
||||
for (const x of [
|
||||
8,
|
||||
...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12),
|
||||
]) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const owner = !hit
|
||||
? "nothing"
|
||||
: hit === document.documentElement || hit === document.body
|
||||
? "page"
|
||||
: hit.closest("aside")
|
||||
? "rail"
|
||||
: hit.closest("#main-content")
|
||||
? "main"
|
||||
: hit.closest('[data-slot="bottom-nav"]')
|
||||
? "bar"
|
||||
: hit.closest('[data-slot="coach-fab"]')
|
||||
? "fab"
|
||||
: `other:${hit.tagName}`;
|
||||
owners.push(owner);
|
||||
}
|
||||
return owners;
|
||||
});
|
||||
}
|
||||
|
||||
const INSET_VIEWPORTS = [
|
||||
{ name: "iPad mini portrait", width: 744, height: 1133 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of INSET_VIEWPORTS) {
|
||||
test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await openDashboard(page);
|
||||
await setBottomInset(page, HOME_INDICATOR);
|
||||
await expectShellPinned(page);
|
||||
|
||||
// The last row of pixels belongs to the rail and `<main>` (or the bar),
|
||||
// never to the page behind the shell: that was the band.
|
||||
const owners = await bottomRowOwners(page);
|
||||
expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]);
|
||||
|
||||
const railShown = vp.width >= 768;
|
||||
const edge = await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside")!;
|
||||
const column = aside.querySelector('[data-slot="sidebar-column"]');
|
||||
const bar = document.querySelector<HTMLElement>(
|
||||
'[data-slot="bottom-nav"]',
|
||||
)!;
|
||||
return {
|
||||
innerHeight,
|
||||
asideBottom: aside.getBoundingClientRect().bottom,
|
||||
lastRailRowBottom:
|
||||
column?.lastElementChild?.getBoundingClientRect().bottom ?? 0,
|
||||
barBottom: bar.getBoundingClientRect().bottom,
|
||||
barPad: parseFloat(getComputedStyle(bar).paddingBottom),
|
||||
// What a WKWebView browser paints where it keeps the page out: the
|
||||
// root's own background.
|
||||
root: getComputedStyle(document.documentElement).backgroundColor,
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
};
|
||||
});
|
||||
if (railShown) {
|
||||
expect(owners).toContain("rail");
|
||||
expect(owners).toContain("main");
|
||||
// The rail's background runs to the edge; its account row stops
|
||||
// above the inset.
|
||||
expect(edge.asideBottom).toBe(edge.innerHeight);
|
||||
expect(edge.lastRailRowBottom).toBeLessThanOrEqual(
|
||||
edge.innerHeight - HOME_INDICATOR,
|
||||
);
|
||||
expect(edge.root).toBe(edge.body);
|
||||
} else {
|
||||
expect(owners).toContain("bar");
|
||||
expect(edge.barBottom).toBe(edge.innerHeight);
|
||||
expect(edge.barPad).toBe(HOME_INDICATOR);
|
||||
expect(edge.root).toBe(edge.body);
|
||||
}
|
||||
|
||||
// At the end of the page, the last line stands clear of the inset, the
|
||||
// bar and the Coach button.
|
||||
await scrollMainToEnd(page);
|
||||
const end = await measureMainEnd(page);
|
||||
expect(end.atEnd).toBe(true);
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(
|
||||
edge.innerHeight - HOME_INDICATOR,
|
||||
);
|
||||
if (end.barTop !== null) {
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
|
||||
}
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible();
|
||||
const fabTop = (await fab.boundingBox())!.y;
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(fabTop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coach button over the last card of a nested layout (Insights, Settings,
|
||||
* a values list): during the scroll it may sit over content, at the end it
|
||||
* must not. Rendered by serving an `ai` block with the Coach available,
|
||||
* since the suite has no provider.
|
||||
*/
|
||||
const NESTED_PAGES = [
|
||||
"/insights/blood-pressure",
|
||||
"/insights/values/BLOOD_PRESSURE_SYS",
|
||||
"/insights",
|
||||
"/settings/account",
|
||||
"/settings/modules",
|
||||
] as const;
|
||||
|
||||
for (const vp of [
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
] as const) {
|
||||
for (const inset of [0, HOME_INDICATOR]) {
|
||||
test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
for (const path of NESTED_PAGES) {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('#main-content [data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (inset) await setBottomInset(page, inset);
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible();
|
||||
// A section near the end can still load once the scroll reaches it
|
||||
// and show its skeleton for a moment; what has to hold is the page
|
||||
// as it ends up, so scroll to the end and measure again until the
|
||||
// last line is in place. Content that stays under the button fails.
|
||||
const coveredAtEnd = async () => {
|
||||
await scrollMainToEnd(page);
|
||||
return page.evaluate(() => {
|
||||
const f = document
|
||||
.querySelector('[data-slot="coach-fab"]')!
|
||||
.getBoundingClientRect();
|
||||
const main = document.getElementById("main-content")!;
|
||||
const under: string[] = [];
|
||||
for (const el of main.querySelectorAll<HTMLElement>("*")) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position === "fixed" || cs.visibility === "hidden")
|
||||
continue;
|
||||
const paints =
|
||||
(el.childElementCount === 0 && !!el.textContent?.trim()) ||
|
||||
cs.borderBottomWidth !== "0px" ||
|
||||
cs.backgroundColor !== "rgba(0, 0, 0, 0)";
|
||||
if (!paints) continue;
|
||||
const b = el.getBoundingClientRect();
|
||||
if (!b.width || !b.height) continue;
|
||||
if (
|
||||
b.right > f.left &&
|
||||
b.left < f.right &&
|
||||
b.bottom > f.top &&
|
||||
b.top < f.bottom
|
||||
) {
|
||||
under.push(el.getAttribute("data-slot") ?? el.tagName);
|
||||
}
|
||||
}
|
||||
return under;
|
||||
});
|
||||
};
|
||||
await expect
|
||||
.poll(coveredAtEnd, {
|
||||
message: `${path}: content under the Coach button`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toEqual([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
|
||||
* left edge in portrait, which no browser under test reports. The shell and
|
||||
* the rail read the side insets through `--safe-area-left` / `-right`
|
||||
* (globals.css), so setting them is the same inset to both.
|
||||
*/
|
||||
const SIDE_INSET = 10;
|
||||
|
||||
async function setSideInsets(page: Page, px: number) {
|
||||
await page.addStyleTag({
|
||||
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
|
||||
});
|
||||
}
|
||||
|
||||
const SIDE_INSET_VIEWPORTS = [
|
||||
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of SIDE_INSET_VIEWPORTS) {
|
||||
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await openDashboard(page);
|
||||
await setSideInsets(page, SIDE_INSET);
|
||||
await setBottomInset(page, HOME_INDICATOR);
|
||||
await expectShellPinned(page);
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside")!;
|
||||
const box = (el: Element | null) => {
|
||||
const r = el!.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
// What paints a pixel: the rail, or the page behind the shell (the
|
||||
// root, the body, or one of the shell's own unpainted boxes around the
|
||||
// rail and `<main>`), or some other chrome that meets the edge.
|
||||
const owner = (x: number, y: number) => {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit) return "nothing";
|
||||
if (hit.closest("aside")) return "rail";
|
||||
const unpainted =
|
||||
hit === document.documentElement ||
|
||||
hit === document.body ||
|
||||
(hit.id !== "main-content" &&
|
||||
hit.contains(document.getElementById("main-content")));
|
||||
return unpainted ? `page:${hit.tagName}` : "chrome";
|
||||
};
|
||||
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
|
||||
const account = aside.querySelector(
|
||||
'[data-slot="sidebar-column"]',
|
||||
)!.lastElementChild!;
|
||||
const avatar = account.querySelector('[data-slot="avatar"]')!;
|
||||
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
|
||||
const links = [...aside.querySelectorAll("nav a[href]")].map(
|
||||
(a) => a.getBoundingClientRect().left,
|
||||
);
|
||||
const headerBox = box(header);
|
||||
const accountBox = box(account);
|
||||
return {
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
aside: box(aside),
|
||||
header: headerBox,
|
||||
account: accountBox,
|
||||
avatar: box(avatar),
|
||||
logoLeft: box(header.querySelector("svg")).left,
|
||||
footerLeft: box(footer).left,
|
||||
firstLinkLeft: Math.min(...links),
|
||||
// Down the window's first column of pixels, and its last.
|
||||
leftEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
(accountBox.top + accountBox.bottom) / 2,
|
||||
innerHeight - 2,
|
||||
].map((y) => owner(1, y)),
|
||||
rightEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
].map((y) => owner(innerWidth - 1, y)),
|
||||
};
|
||||
});
|
||||
|
||||
// No strip: the rail paints the inset from the logo band to the bottom
|
||||
// edge, and the top bar and `<main>` (or a docked panel) meet the right
|
||||
// edge.
|
||||
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
|
||||
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
|
||||
expect(m.aside.left).toBe(0);
|
||||
// The band lines run to the edge: the logo band, the account band and
|
||||
// the footer group (whose scroll edge draws a line) start at x = 0.
|
||||
expect(m.header.left).toBe(0);
|
||||
expect(m.account.left).toBe(0);
|
||||
expect(m.footerLeft).toBe(0);
|
||||
// And everything in the rail stays clear of the inset.
|
||||
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
|
||||
// The account band mirrors the logo band: the same height, the avatar
|
||||
// centred in it, and the home indicator's inset below it.
|
||||
expect(m.account.bottom - m.account.top).toBe(
|
||||
m.header.bottom - m.header.top,
|
||||
);
|
||||
const above = m.avatar.top - m.account.top;
|
||||
const below = m.account.bottom - m.avatar.bottom;
|
||||
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
|
||||
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
|
||||
expect(m.aside.bottom).toBe(m.innerHeight);
|
||||
});
|
||||
}
|
||||
@@ -845,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as
|
||||
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
|
||||
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
|
||||
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
|
||||
// The shell's own buffer below the last card (pb-20 and the top
|
||||
// padding), nothing more.
|
||||
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
|
||||
// The shell's own buffer below the last card (the desktop shell's
|
||||
// pb-24 and the top padding), nothing more.
|
||||
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ test.describe("Today overview geometry", () => {
|
||||
const hero = await openDashboard(page, digest);
|
||||
const phone = viewport.width < 768;
|
||||
|
||||
// Late banners (the key-backup nudge) insert above the hero; let
|
||||
// Late banners (the recovery kit nudge) insert above the hero; let
|
||||
// the page settle so the capture shows one stable layout.
|
||||
await page.waitForLoadState("networkidle");
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
+34
-10
@@ -16,22 +16,42 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
E2E_VAULT_LAYOUT,
|
||||
STORAGE_STATE_PATH,
|
||||
VAULT_LAYOUT_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
async function resetDocumentsLayout(page: Page): Promise<void> {
|
||||
async function putDocumentsLayout(
|
||||
page: Page,
|
||||
arrangement: "stacked" | "flow",
|
||||
): Promise<void> {
|
||||
const res = await page.request.put("/api/documents/inbound/layout", {
|
||||
data: { version: 1, view: "cards", arrangement: "stacked" },
|
||||
data: { version: 1, view: "cards", arrangement },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
/** The journey starts from the stacked arrangement so the flip is visible. */
|
||||
const resetDocumentsLayout = (page: Page) =>
|
||||
putDocumentsLayout(page, "stacked");
|
||||
|
||||
/** And leaves its account on the default, as a new account would read it. */
|
||||
const restoreDefaultDocumentsLayout = (page: Page) =>
|
||||
putDocumentsLayout(page, "flow");
|
||||
|
||||
// The vault's layout is account state, and this journey turns it, so these
|
||||
// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month
|
||||
// grid tests in `documents.spec.ts` read the default on theirs.
|
||||
test.describe("documents", () => {
|
||||
test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
await ensureVaultFixture(E2E_VAULT_LAYOUT.username);
|
||||
});
|
||||
|
||||
test("the display choice behind the wrench persists across visits", async ({
|
||||
@@ -56,11 +76,15 @@ test.describe("documents", () => {
|
||||
settings.locator('[data-slot="module-view-list"]'),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
|
||||
// Wait for the write to land, so the reload below reads it back.
|
||||
// Wait for THIS write to land, so the reload below reads it back. The
|
||||
// view switch above writes too, and its answer can arrive after this
|
||||
// wait starts; matching on the arrangement keeps the two apart.
|
||||
const saved = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/documents/inbound/layout") &&
|
||||
r.request().method() === "PUT",
|
||||
r.request().method() === "PUT" &&
|
||||
(r.request().postDataJSON() as { arrangement?: string } | null)
|
||||
?.arrangement === "flow",
|
||||
);
|
||||
await flow.click();
|
||||
expect((await saved).status()).toBe(200);
|
||||
@@ -87,7 +111,7 @@ test.describe("documents", () => {
|
||||
).toHaveAttribute("data-arrangement", "flow");
|
||||
await second.close();
|
||||
} finally {
|
||||
await resetDocumentsLayout(page);
|
||||
await restoreDefaultDocumentsLayout(page);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,7 +120,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
||||
await seedNamespaceDocs("qoldel", 2);
|
||||
await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qoldel");
|
||||
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
|
||||
await expect(box).toBeVisible();
|
||||
@@ -140,7 +164,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "keyboard flow");
|
||||
await seedNamespaceDocs("qolkey", 1);
|
||||
await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qolkey");
|
||||
const open = page.getByRole("button", { name: "Open qolkey 001" });
|
||||
await expect(open).toBeVisible();
|
||||
@@ -158,7 +182,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
||||
await seedNamespaceDocs("qolclear", 2);
|
||||
await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qolclear");
|
||||
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
|
||||
|
||||
|
||||
@@ -214,6 +214,9 @@ const eslintConfig = defineConfig([
|
||||
"src/components/charts/chart-runtime.ts",
|
||||
"src/components/admin/host-metrics-chart.tsx",
|
||||
"src/components/charts/health-chart.tsx",
|
||||
// HealthChart's plot layers, imported by health-chart.tsx alone, so
|
||||
// they sit inside the same chart-runtime graph.
|
||||
"src/components/charts/health-chart/chart-layers.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
"src/components/charts/mood-chart.tsx",
|
||||
"src/components/charts/nutrient-daily-bar-chart.tsx",
|
||||
@@ -236,6 +239,9 @@ const eslintConfig = defineConfig([
|
||||
"src/components/charts/workout-elevation-chart.tsx",
|
||||
"src/components/insights/coach-panel/result-chart.tsx",
|
||||
"src/components/insights/derived/score-trend-chart.tsx",
|
||||
// Browser tests draw a chart with Recharts itself to pin what it lays
|
||||
// out; a test file is never bundled, so it mints no chunk.
|
||||
"src/**/*.browser.test.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
"e2e/**/*.test.ts",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/**/setup.ts",
|
||||
// Reached only through the browser project's `next/navigation` alias in
|
||||
// vitest.config.mts, which no import statement names.
|
||||
"tests/browser/next-navigation.ts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.integration.ts",
|
||||
"playwright.config.ts",
|
||||
@@ -111,6 +114,7 @@
|
||||
"src/**/*.{ts,tsx}!",
|
||||
"scripts/**/*.{ts,mjs}!",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx",
|
||||
"e2e/**/*.ts",
|
||||
],
|
||||
"ignore": [
|
||||
|
||||
+339
-147
@@ -175,6 +175,9 @@
|
||||
"failed": "Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein",
|
||||
"window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen",
|
||||
"windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} von {total} Ergebnissen wurden in einer nicht wissenschaftlich validierten Übersetzung beantwortet; sie dienen der Orientierung."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Einladung ungültig",
|
||||
"description": "Dieser Einladungslink ist unvollständig oder falsch kopiert. Bitte die Person, die dich eingeladen hat, um einen neuen Link.",
|
||||
"toLogin": "Zur Anmeldung"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.",
|
||||
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Sichere deinen Verschlüsselungsschlüssel",
|
||||
"body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
|
||||
"action": "Zum Schritt"
|
||||
},
|
||||
"quickAddSymptom": "Symptom erfassen",
|
||||
"todayCardVisible": "Obere Karte anzeigen"
|
||||
"todayCardVisible": "Obere Karte anzeigen",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Wiederherstellungs-Kit sichern",
|
||||
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
|
||||
"action": "Kit öffnen"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
|
||||
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"errorReasoningOnly": "Das Modell hat nur nachgedacht und keine Antwort geschrieben. Versuche es noch einmal oder wähle in den KI-Einstellungen ein anderes Modell.",
|
||||
"errorDemo": "In der Demo antwortet der Coach nicht.",
|
||||
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
|
||||
"errorNoProviderAction": "KI-Anbieter verbinden",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
|
||||
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
|
||||
"noRecoveryWarning": "Es gibt keine Wiederherstellung. Geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
|
||||
"detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten; das Manifest der Datei nennt, was drinsteht.",
|
||||
"detail": "Eine portable Kopie deiner Akte zum Aufbewahren und Lesen: Werte als einfaches JSON, Dokumente nur mit ihren Metadaten. Sie ist nicht die wöchentliche Kontokopie, mit der dieser Server ein Konto zurücksetzt, und sie kann kein Konto auf einem anderen Server neu anlegen. Einige Datenarten fehlen noch; das Manifest der Datei nennt, was enthalten ist.",
|
||||
"contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "JSON-Datei",
|
||||
"title": "Messwerte und Stimmung (JSON)",
|
||||
"description": "JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.",
|
||||
"pasteLabel": "JSON einfügen",
|
||||
"fileInputLabel": "Eine JSON-Datei zum Import auswählen",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Diese Datei konnte nicht gelesen werden.",
|
||||
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
|
||||
"resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert",
|
||||
"droppedLinks": "Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}",
|
||||
"keptLabels": "Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}",
|
||||
"resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
|
||||
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
|
||||
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge.",
|
||||
"limitsHint": "Bis {mb} MB und bis zu {rows} Einträge je Liste. Das ergänzt deine Akte um Einträge; ein vollständiges Backup lässt sich so nicht wiederherstellen."
|
||||
},
|
||||
"csv": {
|
||||
"title": "CSV-Datei",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
|
||||
"subtitle": "Exportiere deine Daten oder importiere Messwerte und Einträge aus anderen Apps und Dateien."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über diese App",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "Das Token wird nur einmal angezeigt.",
|
||||
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.",
|
||||
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||
"namePlaceholder": "Name, z. B. Home Assistant"
|
||||
"namePlaceholder": "Name, z. B. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Messwerte in Apple Health schreiben",
|
||||
"createHint": "Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.",
|
||||
"toggleAria": "Werte von {name} in Apple Health schreiben",
|
||||
"updateFailed": "Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||
"unattributed": "Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Training-Tokens",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions.",
|
||||
"deleteAccountCta": "Konto löschen",
|
||||
"deleteAccountConfirmTitle": "Konto endgültig löschen?",
|
||||
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.",
|
||||
"deleteAccountConfirmTitleGrace": "Konto löschen?",
|
||||
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
|
||||
"deleteAccountSuccess": "Konto wurde gelöscht. Du wirst abgemeldet.",
|
||||
"deleteAccountFailed": "Konto konnte nicht gelöscht werden",
|
||||
"deleteAccountFinal": "Endgültig löschen",
|
||||
"deleteAccountFinalGrace": "Konto löschen",
|
||||
"saved": "Gespeichert",
|
||||
"savingError": "Fehler beim Speichern",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Coach-Unterhaltungen: {days} Tage",
|
||||
"audit": "Sicherheitsprotokoll: {days} Tage",
|
||||
"delivery": "Benachrichtigungsprotokoll: {days} Tage",
|
||||
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar."
|
||||
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar.",
|
||||
"accountDeletion": "Löschen deines Kontos, eines verwalteten Profils oder aller Daten: abgeschlossen nach {days} Tagen",
|
||||
"accountDeletionImmediate": "Löschen deines Kontos, eines verwalteten Profils oder aller Daten: sofort abgeschlossen"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Löschen oder zurücksetzen",
|
||||
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
|
||||
"backupLag": "Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist.",
|
||||
"openAdvanced": "Löschen oder zurücksetzen"
|
||||
"openAdvanced": "Löschen oder zurücksetzen",
|
||||
"immediateNotice": "Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
|
||||
"graceNotice": "Wenn du dein Konto löschst, wird es sofort gesperrt und nach {days} Tagen endgültig gelöscht, zusammen mit jeder Kopie, die dieser Server davon aufbewahrt, auch verschlüsselten Kopien außerhalb des Servers. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Beim Löschen aller Daten gilt dasselbe: Eine Kopie der Akte bleibt {days} Tage erhalten, sodass ein Admin sie wiederherstellen kann. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Datenschutz-Einstellung",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Aus",
|
||||
"change": "Unter Module ändern"
|
||||
},
|
||||
"timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})"
|
||||
"timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Dieser Vorgang löscht alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Eine Kopie deiner Akte wird {days} Tage aufbewahrt, sodass ein Admin dieses Servers sie bis dahin wiederherstellen kann. Danach ist die Löschung endgültig.",
|
||||
"deleteAccountCardDetailGrace": "Dein Konto wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Eigene Sitzungen können nicht beendet werden",
|
||||
"emptyTitle": "Keine Nutzer in dieser Ansicht",
|
||||
"emptyDescription": "Es gibt keine Nutzer, die zum aktuellen Filter passen.",
|
||||
"emptyResetFilter": "Alle Nutzer anzeigen"
|
||||
"emptyResetFilter": "Alle Nutzer anzeigen",
|
||||
"deletionPending": "Löschung am {date}",
|
||||
"deletionPendingNoDate": "Löschung vorgemerkt",
|
||||
"undoDeletion": "Löschung rückgängig machen",
|
||||
"undoDeletionTitle": "Löschung von {name} rückgängig machen?",
|
||||
"undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
|
||||
"undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
|
||||
"undoDeletionDone": "Löschung von {name} rückgängig gemacht",
|
||||
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Einladungen",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.",
|
||||
"description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.",
|
||||
"subtitle": "Kontokopien in der Datenbank dieses Servers, um ein Konto hier zurückzusetzen.",
|
||||
"description": "Jedes Konto hat eine wöchentliche Kopie, die jeden Sonntag ersetzt wird, dazu hier hochgeladene Kopien und die Kopie, die vor „Alle Daten löschen“ angelegt wird. Eine Kopie setzt ein bestehendes Konto auf diesem Server zurück. Einen verlorenen Server kann sie nicht wiederherstellen: Dafür braucht es einen Datenbank-Dump und die Verschlüsselungsschlüssel. Die Größen zeigen die verschlüsselte Kopie. Beim Herunterladen wird sie für den anfragenden Admin entschlüsselt; der Geheimtext bleibt in der Datenbank.",
|
||||
"runNow": "Jetzt sichern",
|
||||
"runEnqueued": "Backup-Job eingeplant",
|
||||
"runFailed": "Backup konnte nicht eingeplant werden",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr, oder erstelle jetzt einen ersten Snapshot.",
|
||||
"colUser": "Nutzer",
|
||||
"colType": "Typ",
|
||||
"typeWeeklyAuto": "Wöchentlich (auto)",
|
||||
"typeWeeklyAuto": "Wöchentliche Kontokopie",
|
||||
"typeManual": "Manuell",
|
||||
"colSize": "Größe",
|
||||
"colCreatedAt": "Letzter Snapshot",
|
||||
"colActions": "Aktionen",
|
||||
"retentionLabel": "Aufbewahrung: {days} Tage",
|
||||
"scheduleStaleTitle": "Die wöchentliche Sicherung läuft nicht mehr",
|
||||
"scheduleStaleDescription": "Die neueste geplante Kopie ist {days} Tage alt. Ein funktionierender wöchentlicher Lauf lässt keine älter als {threshold} Tage werden.",
|
||||
"scheduleFailedTitle": "Der letzte geplante Sicherungslauf ist fehlgeschlagen",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet",
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Kontoeinstellung",
|
||||
"restoreMissingTitle": "Wiederherstellung abgelehnt: Die Datei ist unvollständig",
|
||||
"restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.",
|
||||
"restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.",
|
||||
"restoreMissingRemedy": "Nutze eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers, oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest als ausgelassen angibt, ist etwas anderes und wird normal wiederhergestellt.",
|
||||
"restoreMissingDocuments": "Dokumente",
|
||||
"restoreMissingWorkouts": "Workouts",
|
||||
"restoreMissingMentalHealth": "Psychische Fragebögen",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
|
||||
"restoreJobQueuedTitle": "Wiederherstellung von {username} wartet auf den Start",
|
||||
"restoreJobQueuedNote": "Sie beginnt, sobald der Hintergrunddienst sie übernimmt.",
|
||||
"restoreJobWaitingBackupNote": "Wartet auf das Backup, das gerade auf dem Server läuft. Eine Wiederherstellung läuft nie neben einem Backup und startet etwa eine Minute nach dessen Ende.",
|
||||
"restoreJobWaitingBusyNote": "Wartet, bis eine große Aufgabe auf dem Server fertig ist, etwa ein Import oder ein nächtlicher Durchlauf. Sie startet etwa eine Minute danach.",
|
||||
"restoreJobQueuedSince": "In der Warteschlange seit {when}.",
|
||||
"restoreJobRunningTitle": "{username} wird wiederhergestellt",
|
||||
"restoreJobRunningNote": "Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.",
|
||||
"restoreJobResumed": "Nach einem Neustart des Servers erneut gestartet.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.",
|
||||
"restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.",
|
||||
"restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.",
|
||||
"restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.",
|
||||
"restoreFailureDocument": "Die Datei enthält Dokumente ohne ihren Inhalt, wie ein portabler Export. Nutze stattdessen eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers.",
|
||||
"restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.",
|
||||
"restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.",
|
||||
"restoreFailureCauseTimeout": "Die Wiederherstellung hat länger gedauert, als die Datenbank zulässt, deshalb wurde alles zurückgerollt. War der Datenbank-Host ausgelastet, starte sie erneut, wenn er ruhiger ist. Das Server-Log nennt den Schritt.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
|
||||
"restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
|
||||
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)",
|
||||
"typePreWipe": "Vor dem Löschen aller Daten",
|
||||
"retentionNote": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Eine Kopie, die vor „Alle Daten löschen“ angelegt wurde, wird nach {days} Tagen gelöscht. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
|
||||
"retentionNoteNoGrace": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
|
||||
"database": {
|
||||
"title": "Datenbank-Dump",
|
||||
"lastGood": "Letzter gültiger Dump {when}, {size}, {where}.",
|
||||
"whereBoth": "auf diesem Server und extern gespeichert",
|
||||
"whereLocal": "auf diesem Server gespeichert",
|
||||
"whereOffhost": "nur extern gespeichert",
|
||||
"none": "Noch kein Datenbank-Dump. Der erste läuft heute Nacht.",
|
||||
"failedTitle": "Der letzte Datenbank-Dump ist fehlgeschlagen",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.",
|
||||
"noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.",
|
||||
"noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.",
|
||||
"retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights} nächtlichen Dumps, und der Dump vor dem letzten Update bleibt bis zum nächsten Update.",
|
||||
"retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.",
|
||||
"retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
|
||||
"notPersistentOffhost": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.",
|
||||
"notPersistent": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein.",
|
||||
"offhostFailedTitle": "Der neueste Datenbank-Dump ist nicht im externen Bucket angekommen",
|
||||
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
|
||||
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
|
||||
},
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Tabelle ausblenden",
|
||||
"columnsShowAll": "Alle {count} Spalten anzeigen",
|
||||
"columnsShowWithRows": "Nur Spalten mit Zeilen",
|
||||
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen."
|
||||
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen.",
|
||||
"backupsDumpLocalLine": "Schlüssel {key}: {count} Datenbank-Dumps auf diesem Server wurden unter ihm geschrieben; er wird gebraucht, bis die Aufbewahrung sie entfernt.",
|
||||
"backupsDumpOffhostLine": "Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket; dort gebraucht bis {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket. Wie lange der Bucket database/ behält, ist unbekannt; behalte den Schlüssel, bis dieser Dump sicher weg ist."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Pflege der Messwert-Tabelle",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Dieser Teil der Seite konnte nicht angezeigt werden",
|
||||
"description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Sichere deinen Verschlüsselungsschlüssel",
|
||||
"description": "Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.",
|
||||
"loading": "Status der Schlüsselsicherung wird geladen…",
|
||||
"loadError": "Der Status der Schlüsselsicherung konnte nicht geladen werden.",
|
||||
"badgeDue": "Offen",
|
||||
"badgeDone": "Gesichert",
|
||||
"keyId": "Aktive Schlüssel-ID",
|
||||
"fingerprint": "Fingerabdruck",
|
||||
"whatIsLost": "Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.",
|
||||
"fingerprintHint": "Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.",
|
||||
"checkLabel": "Meine Kopie prüfen",
|
||||
"checkButton": "Prüfen",
|
||||
"checkNote": "Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
|
||||
"checkInsecureNote": "Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
|
||||
"checkMatches": "Deine Kopie passt zum aktiven Schlüssel.",
|
||||
"checkNoMatch": "Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
|
||||
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
|
||||
"confirmButton": "Ich habe diesen Schlüssel gesichert",
|
||||
"confirmed": "Schlüsselsicherung bestätigt.",
|
||||
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
|
||||
"stale": "Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.",
|
||||
"confirmedAt": "Bestätigt am {date}.",
|
||||
"confirmedAtBy": "Bestätigt am {date} von {who}.",
|
||||
"dueAgain": "Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.",
|
||||
"tileLabel": "Sicherung des Verschlüsselungsschlüssels",
|
||||
"tileDue": "Nicht bestätigt",
|
||||
"tileDone": "Bestätigt",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog kann seine Daten nicht öffnen",
|
||||
"body": "Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.",
|
||||
"gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
|
||||
"gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Wiederherstellungs-Kit",
|
||||
"description": "Jeder Schlüssel, den eine Wiederherstellung dieses Servers braucht, mit Kennung und Fingerabdruck.",
|
||||
"intro": "Bewahre von jedem Schlüssel unten eine Kopie außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Ohne sie lassen sich weder ein Datenbank-Dump noch eine Sicherungskopie wieder lesen, und HealthLog startet nicht, wenn ein benötigter Schlüssel fehlt.",
|
||||
"loading": "Wiederherstellungs-Kit wird geladen…",
|
||||
"loadError": "Das Wiederherstellungs-Kit konnte nicht geladen werden.",
|
||||
"badgeDue": "Offen",
|
||||
"badgeDone": "Gesichert",
|
||||
"keyId": "Kennung",
|
||||
"fingerprint": "Fingerabdruck",
|
||||
"noFingerprint": "Nicht auf diesem Server",
|
||||
"kind": {
|
||||
"application": "Anwendungsschlüssel {id}",
|
||||
"backup": "Schlüssel für externe Sicherungen",
|
||||
"backupPrevious": "Früherer Schlüssel für externe Sicherungen",
|
||||
"token": "Token-Schlüssel"
|
||||
},
|
||||
"need": {
|
||||
"active": "Verschlüsselt neue Daten.",
|
||||
"configured": "Liegt auf diesem Server.",
|
||||
"storedCopies": "Wird von {count} gespeicherten Kopien gebraucht.",
|
||||
"offhostUntil": "Wird von externen Kopien bis {date} gebraucht.",
|
||||
"offhostUnknown": "Wird von externen Kopien gebraucht, solange der Bucket sie aufbewahrt.",
|
||||
"backupActive": "Versiegelt jede neue externe Kopie.",
|
||||
"backupPrevious": "Öffnet externe Kopien, die vor dem letzten Schlüsselwechsel geschrieben wurden.",
|
||||
"token": "Signiert Sitzungen, Tokens und Freigabelinks. Ohne ihn werden alle abgemeldet, Daten gehen aber nicht verloren.",
|
||||
"localDumps": "Wird von {count} Datenbank-Dumps auf diesem Server gebraucht.",
|
||||
"offhostDumpsUntil": "Wird von externen Datenbank-Dumps bis {date} gebraucht.",
|
||||
"offhostDumpsUnknown": "Wird von externen Datenbank-Dumps gebraucht, solange der Bucket sie aufbewahrt."
|
||||
},
|
||||
"source": "Gesetzt in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Gesichert",
|
||||
"new": "Noch nicht gesichert",
|
||||
"notConfigured": "Nicht auf diesem Server",
|
||||
"unreadable": "Kein gültiger Schlüssel"
|
||||
},
|
||||
"notConfiguredHint": "Eine Sicherung braucht diesen Schlüssel noch, aber dieser Server hat ihn nicht. Nur eine Kopie, die du anderswo aufbewahrst, kann diese Sicherungen lesen. Bestätige das Kit, sobald du diese Kopie hast.",
|
||||
"unreadableHint": "Die Variable ist gesetzt, enthält aber keinen gültigen Schlüssel. Behebe das, bevor du dich auf die externen Kopien verlässt.",
|
||||
"fingerprintHint": "Der Fingerabdruck besteht aus den ersten 12 Zeichen des SHA-256-Hashs eines Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, sodass du ihn mit deiner Kopie vergleichen kannst.",
|
||||
"whereTitle": "Wo die Schlüssel gesetzt sind",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Die Schlüssel stehen in der .env-Datei neben docker-compose.yml: ENCRYPTION_KEY oder ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY und, mit externen Kopien, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog und dann Edit. Die Schlüssel stehen unter HealthLog Configuration; bei einer per YAML installierten App sind es ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY in den Umgebungsvariablen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bearbeite auf dem Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Umgebungsvariablen."
|
||||
},
|
||||
"restoreTitle": "Diesen Server wiederherstellen",
|
||||
"restoreBody": "Installiere HealthLog mit denselben Schlüsseln, spiele den neuesten Datenbank-Dump in eine leere Datenbank ein und starte dann die App. Die Anleitung führt durch jeden Schritt.",
|
||||
"restoreLink": "Anleitung zur Notfallwiederherstellung",
|
||||
"checkLabel": "Meine Kopie prüfen",
|
||||
"checkKeyLabel": "Zu prüfender Schlüssel",
|
||||
"checkValueLabel": "Deine Kopie des Schlüssels",
|
||||
"checkButton": "Prüfen",
|
||||
"checkNote": "Der eingefügte Schlüssel wird mit dem auf diesem Server verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
|
||||
"checkInsecureNote": "Diese Seite wird nicht über https:// ausgeliefert. Der eingefügte Schlüssel geht deshalb unverschlüsselt über das Netzwerk, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
|
||||
"checkMatches": "Deine Kopie passt zu diesem Schlüssel.",
|
||||
"checkNoMatch": "Deine Kopie passt nicht zu diesem Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
|
||||
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
|
||||
"confirmButton": "Ich habe von jedem Schlüssel eine Kopie",
|
||||
"confirmed": "Wiederherstellungs-Kit bestätigt.",
|
||||
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
|
||||
"stale": "Das Kit hat sich geändert, seit die Seite geladen wurde. Prüfe die Schlüssel, die es jetzt aufführt, und bestätige erneut.",
|
||||
"confirmedAt": "Bestätigt am {date}.",
|
||||
"confirmedAtBy": "Bestätigt am {date} von {who}.",
|
||||
"dueAgain": "Das Kit wurde am {date} bestätigt. Es führt jetzt auch {keys} auf, was diese Bestätigung nicht abgedeckt hat. Sichere von jedem eine Kopie und bestätige erneut."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Wiederherstellbarkeit",
|
||||
"description": "Ob sich dieser Server nach einem Ausfall wiederherstellen ließe, beurteilt anhand jeder Sicherung, die er anlegt.",
|
||||
"loading": "Sicherungen werden geprüft…",
|
||||
"loadError": "Die Wiederherstellbarkeit konnte nicht geladen werden.",
|
||||
"state": {
|
||||
"ok": "Bereit",
|
||||
"attention": "Braucht Aufmerksamkeit",
|
||||
"failing": "Gefährdet"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Datenbank-Dump",
|
||||
"weekly_copy": "Wöchentliche Kontokopien",
|
||||
"offhost_copies": "Externe Kopien",
|
||||
"restore_drill": "Wiederherstellungsprobe",
|
||||
"offhost_setup": "Externe Sicherung",
|
||||
"recovery_kit": "Wiederherstellungs-Kit",
|
||||
"dump_retention": "Dumps im Bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Neuester Dump: {date}",
|
||||
"weekly_copy": "Älteste Kontokopie: {date}",
|
||||
"offhost_copies": "Älteste externe Kopie: {date}",
|
||||
"restore_drill": "Letzte Probe: {date}",
|
||||
"recovery_kit": "Bestätigt: {date}"
|
||||
},
|
||||
"dueBy": "Fällig bis {date}",
|
||||
"fix": {
|
||||
"database_dump": "Sicherungen öffnen",
|
||||
"weekly_copy": "Sicherungen öffnen",
|
||||
"offhost_copies": "Sicherungen öffnen",
|
||||
"restore_drill": "Sicherungen öffnen",
|
||||
"offhost_setup": "Sicherungen öffnen",
|
||||
"recovery_kit": "Kit öffnen",
|
||||
"dump_retention": "Sicherungen öffnen"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Diese Version legt keinen geplanten Datenbank-Dump an.",
|
||||
"disabled": "Der geplante Datenbank-Dump ist ausgeschaltet, daher kann keine Kopie diesen Server wieder aufbauen. Zum Wiedereinschalten DB_BACKUP_ENABLED entfernen oder auf true setzen und HealthLog neu starten.",
|
||||
"unreadable": "Der Zustand des Datenbank-Dumps konnte nicht gelesen werden.",
|
||||
"never_succeeded": "Noch kein Datenbank-Dump ist gelungen, und der letzte Versuch ist fehlgeschlagen.",
|
||||
"never_ran": "In den {hours} Stunden, seit Dumps eingeschaltet sind, wurde kein Datenbank-Dump geschrieben.",
|
||||
"first_pending": "Der erste Datenbank-Dump ist noch nicht gelaufen.",
|
||||
"stale": "Der neueste Datenbank-Dump ist {hours} Stunden alt.",
|
||||
"last_failed": "Der letzte Datenbank-Dump ist fehlgeschlagen. Der davor ist noch aktuell.",
|
||||
"fresh": "Der neueste Datenbank-Dump ist aktuell.",
|
||||
"nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden.",
|
||||
"not_persistent": "Das Dump-Verzeichnis {directory} liegt nicht auf dauerhaftem Speicher, und es ist kein externer Bucket eingerichtet. Jeder Dump wäre beim nächsten Update verloren. Binde das Backup-Volume ein.",
|
||||
"fresh_offhost_only": "Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt.",
|
||||
"offhost_failed": "Der neueste Datenbank-Dump liegt auf diesem Server, aber das Hochladen in den externen Bucket ist fehlgeschlagen.",
|
||||
"offhost_stale": "Seit {hours} Stunden ist kein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt immer wieder fehl. Geht dieser Server verloren, gibt es keinen aktuellen Dump, um ihn wiederherzustellen.",
|
||||
"offhost_never": "Noch nie ist ein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt fehl. Geht dieser Server verloren, gibt es keinen Dump, um ihn wiederherzustellen."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Es gibt noch keine Konten zum Kopieren.",
|
||||
"stale": "Die älteste wöchentliche Kopie ist {days} Tage alt. Konten im Rückstand: {accounts}.",
|
||||
"never": "Ein Konto hat seit {days} Tagen keine wöchentliche Kopie. Konten im Rückstand: {accounts}.",
|
||||
"first_pending": "Die erste wöchentliche Kopie entsteht am frühen Sonntagmorgen.",
|
||||
"fresh": "Jedes Konto hat eine Kopie aus der letzten Woche."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Es ist kein externer Bucket eingerichtet.",
|
||||
"stale": "Externe Kopien liegen mehr als zwei Nächte zurück. Konten im Rückstand: {accounts}.",
|
||||
"first_pending": "Der nächtliche Lauf für externe Kopien hat noch kein Konto erreicht.",
|
||||
"fresh": "Jedes Konto hat eine aktuelle Kopie im externen Bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Die Probe liest den externen Bucket, und der ist nicht eingerichtet.",
|
||||
"not_run_yet": "Die monatliche Wiederherstellungsprobe ist noch nicht gelaufen.",
|
||||
"failed": "Die letzte Wiederherstellungsprobe ist fehlgeschlagen: {failed} von {checked} Kopien ließen sich nicht zurücklesen.",
|
||||
"failed_run": "Die letzte Wiederherstellungsprobe konnte nicht zu Ende laufen.",
|
||||
"passed": "Die letzte Wiederherstellungsprobe hat {checked} Kopien zurückgelesen.",
|
||||
"nothing_checked": "Die letzte Wiederherstellungsprobe hat ihr Zeitlimit erreicht, bevor sie eine einzige Kopie zurückgelesen hat, und hat damit nichts belegt."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopien verlassen diesen Server jede Nacht.",
|
||||
"no_admin": "Es gibt noch kein Admin-Konto.",
|
||||
"not_configured": "Es ist kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server.",
|
||||
"not_configured_grace": "Es ist noch kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Von jedem Schlüssel, den eine Wiederherstellung braucht, gibt es eine Kopie.",
|
||||
"out_of_date": "Das Wiederherstellungs-Kit führt jetzt auch {keys} auf, was die letzte Bestätigung nicht abgedeckt hat. Nichts ist kaputt: Sichere eine Kopie und bestätige das Kit erneut.",
|
||||
"no_admin": "Es gibt noch kein Admin-Konto.",
|
||||
"not_confirmed": "Niemand hat eine Kopie der Schlüssel bestätigt, die eine Wiederherstellung braucht: {keys}.",
|
||||
"not_confirmed_grace": "Sichere eine Kopie der Schlüssel, die eine Wiederherstellung braucht: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Es ist kein externer Bucket eingerichtet.",
|
||||
"no_offhost_dumps": "Es geht kein Datenbank-Dump in den Bucket.",
|
||||
"missing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
|
||||
"configured": "Der Bucket entfernt Dumps nach {days} Tagen.",
|
||||
"unknown": "Der Bucket gibt nicht an, ob eine Regel alte Dumps entfernt. Prüfe, ob eine Lebenszyklus-Regel database/ erfasst."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Der Datenbank-Dump ist wieder aktuell.",
|
||||
"weekly_copy": "Jedes Konto hat wieder eine aktuelle wöchentliche Kopie.",
|
||||
"offhost_copies": "Jedes Konto hat wieder eine aktuelle externe Kopie.",
|
||||
"restore_drill": "Die Wiederherstellungsprobe ist wieder bestanden.",
|
||||
"offhost_setup": "Externe Kopien sind jetzt eingerichtet.",
|
||||
"recovery_kit": "Das Wiederherstellungs-Kit ist wieder bestätigt.",
|
||||
"dump_retention": "Eine Lebenszyklus-Regel im Bucket erfasst jetzt die Datenbank-Dumps."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen",
|
||||
"offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}",
|
||||
"tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}",
|
||||
"tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Konto konnte nicht gelöscht werden: {username}",
|
||||
"accountErasureRefusedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Konto ist die letzte aktive betreuende Person eines verwalteten Profils und wurde deshalb nicht gelöscht. Es bleibt gesperrt. Füge dem Profil eine weitere betreuende Person hinzu, lösche das Profil oder mache die Löschung unter Administration, Nutzer rückgängig.",
|
||||
"accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Vorsorge-Erinnerungen",
|
||||
"description": "Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
|
||||
},
|
||||
"eventBackupHealth": "Zustand der Sicherungen",
|
||||
"eventBackupHealthDesc": "Wenn eine Sicherung ausbleibt, zurückfällt oder wieder läuft. Nur für Admins.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Sicherungen brauchen Aufmerksamkeit",
|
||||
"titleFailing": "Sicherungen sind gefährdet",
|
||||
"titleReminder": "Sicherungen sind weiterhin gefährdet",
|
||||
"titleRecovered": "Sicherungen laufen wieder normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,25 +11162,6 @@
|
||||
"who5": "Wohlbefindens-Check, 5 Fragen",
|
||||
"sci": "Schlaf-Check, 8 Fragen"
|
||||
},
|
||||
"options": {
|
||||
"0": "Überhaupt nicht",
|
||||
"1": "An einzelnen Tagen",
|
||||
"2": "An mehr als der Hälfte der Tage",
|
||||
"3": "Beinahe jeden Tag"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Überhaupt nicht",
|
||||
"1": "An einzelnen Tagen",
|
||||
"2": "An mehr als der Hälfte der Tage",
|
||||
"3": "Beinahe jeden Tag"
|
||||
},
|
||||
"functionalTitle": "Wenn eines oder mehrere der bisher in diesem Fragebogen beschriebenen Probleme bei Ihnen vorliegen, geben Sie bitte an, wie sehr diese Probleme es Ihnen erschwert haben, Ihre Arbeit zu tun, Ihren Haushalt zu regeln oder mit anderen Menschen zurecht zu kommen:",
|
||||
"functional": {
|
||||
"0": "Überhaupt nicht erschwert",
|
||||
"1": "Etwas erschwert",
|
||||
"2": "Stark erschwert",
|
||||
"3": "Extrem erschwert"
|
||||
},
|
||||
"functionalOptionalHint": "Optional: Du kannst auch ohne Antwort auf diese Frage absenden.",
|
||||
"result": {
|
||||
"title": "Dein Ergebnis",
|
||||
@@ -11015,35 +11211,6 @@
|
||||
"ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.",
|
||||
"resourcesTitle": "Menschen, die helfen können"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Wenig Interesse oder Freude an Ihren Tätigkeiten",
|
||||
"2": "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit",
|
||||
"3": "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf",
|
||||
"4": "Müdigkeit oder Gefühl, keine Energie zu haben",
|
||||
"5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen",
|
||||
"6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben",
|
||||
"7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen",
|
||||
"8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten dadurch einen stärkeren Bewegungsdrang als sonst?",
|
||||
"9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Nervosität, Ängstlichkeit oder Anspannung",
|
||||
"2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren",
|
||||
"3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten",
|
||||
"4": "Schwierigkeiten zu entspannen",
|
||||
"5": "Rastlosigkeit, so dass Stillsitzen schwer fällt",
|
||||
"6": "Schnelle Verärgerung oder Gereiztheit",
|
||||
"7": "Gefühl der Angst, so als würde etwas Schlimmes passieren"
|
||||
},
|
||||
"who5": {
|
||||
"1": "… war ich froh und guter Laune",
|
||||
"2": "… habe ich mich ruhig und entspannt gefühlt",
|
||||
"3": "… habe ich mich energisch und aktiv gefühlt",
|
||||
"4": "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
|
||||
"5": "… war mein Alltag voller Dinge, die mich interessieren"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)"
|
||||
@@ -11102,26 +11269,6 @@
|
||||
"who5": "Ein Wohlbefindens-Wert von 50 oder darunter kann einen genaueren Blick wert sein. Wenn du magst, schaut der PHQ-9-Check-in auf dieser Seite genauer auf die Stimmung. Das ist ein behutsamer Hinweis, keine Diagnose.",
|
||||
"sci": "In der Studie zu diesem Fragebogen waren Werte von 16 oder darunter mit wahrscheinlicher Insomnie verbunden. Wenn schlechter Schlaf dich weiter belastet, kann ein Gespräch mit einer Fachperson helfen. Das ist keine Diagnose."
|
||||
},
|
||||
"validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
|
||||
"who5Options": {
|
||||
"0": "Zu keinem Zeitpunkt",
|
||||
"1": "Ab und zu",
|
||||
"2": "Etwas weniger als die Hälfte der Zeit",
|
||||
"3": "Etwas mehr als die Hälfte der Zeit",
|
||||
"4": "Meistens",
|
||||
"5": "Die ganze Zeit"
|
||||
},
|
||||
"stems": {
|
||||
"phq9": {
|
||||
"period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?"
|
||||
},
|
||||
"gad7": {
|
||||
"period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "In den letzten zwei Wochen …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Details anzeigen",
|
||||
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.",
|
||||
@@ -11171,6 +11318,13 @@
|
||||
"belowThreshold": "Dein Schlaf lag im letzten Monat in dem Bereich, in dem die Studie hinter diesem Fragebogen eine Insomnie für wahrscheinlich hielt.",
|
||||
"aboveThreshold": "Dein Schlaf lag im letzten Monat außerhalb des Bereichs, den diese Studie mit Insomnie verbindet."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.",
|
||||
"showOriginal": "Englischen Originaltext zeigen",
|
||||
"showTranslation": "Übersetzung zeigen",
|
||||
"resultUnvalidated": "Beantwortet in einer Übersetzung, die nicht wissenschaftlich validiert ist; nimm das Ergebnis als Orientierung.",
|
||||
"historyUnvalidated": "Beantwortet in einer nicht wissenschaftlich validierten Übersetzung"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11698,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.",
|
||||
"deleted": "Dokument gelöscht",
|
||||
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden."
|
||||
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden.",
|
||||
"deletedHint": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Mehr wird geladen…",
|
||||
@@ -11743,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Dieses Dokument konnte nicht geladen werden.",
|
||||
@@ -11815,7 +11971,7 @@
|
||||
"dismiss": "Verwerfen",
|
||||
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
|
||||
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
|
||||
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableLocalOcr": "Schalte „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableAction": "KI-Einstellungen öffnen",
|
||||
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
|
||||
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
|
||||
@@ -11827,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
|
||||
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
|
||||
"errorLocalOcrDisabled": "Schalte zuerst „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein.",
|
||||
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
|
||||
"errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
|
||||
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar."
|
||||
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar.",
|
||||
"unavailableLocalOcrAction": "Labor-Einstellungen öffnen"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Zusammenfassen",
|
||||
@@ -11887,6 +12045,7 @@
|
||||
"extracting": "Liest aus…",
|
||||
"extractHint": "Nutzt den bereits ausgelesenen Text dieses Dokuments. Ausgelesene Werte warten auf deine Prüfung; nichts wird automatisch gespeichert.",
|
||||
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
|
||||
"extractIncomplete": "Ein Teil des Dokuments konnte nicht vollständig gelesen werden, deshalb können Werte fehlen. Vergleiche die Liste mit dem Dokument.",
|
||||
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
|
||||
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden.",
|
||||
@@ -11934,6 +12093,7 @@
|
||||
"loadMore": "Mehr laden",
|
||||
"stateImported": "In HealthLog",
|
||||
"stateDeleted": "In HealthLog gelöscht",
|
||||
"stateInTrash": "Unter Zuletzt gelöscht",
|
||||
"selectedCount": "{count} von höchstens {max} ausgewählt",
|
||||
"importAction": "{count} importieren",
|
||||
"importing": "Import läuft, ein Dokument nach dem anderen …",
|
||||
@@ -11947,7 +12107,9 @@
|
||||
"importedLinked": "Importiert und verknüpft",
|
||||
"duplicate": "Schon in HealthLog",
|
||||
"duplicateLinked": "Schon in HealthLog, jetzt verknüpft",
|
||||
"deleted": "In HealthLog gelöscht, bleibt gelöscht"
|
||||
"deleted": "In HealthLog gelöscht, bleibt gelöscht",
|
||||
"restored": "Aus Zuletzt gelöscht wiederhergestellt",
|
||||
"restoredLinked": "Aus Zuletzt gelöscht wiederhergestellt und verknüpft"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Dokumentarchive sind auf diesem Server nicht freigeschaltet.",
|
||||
@@ -11991,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Dieses Dokument löschen?",
|
||||
"titleMany": "{count} Dokumente löschen?",
|
||||
"body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.",
|
||||
"body": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht, danach werden sie endgültig entfernt.",
|
||||
"action": "Löschen"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Zuletzt gelöscht",
|
||||
"subtitle": "Gelöschte Dokumente bleiben {days} Tage hier, danach werden sie endgültig entfernt.",
|
||||
"back": "Dokumente",
|
||||
"entryLabel": "Zuletzt gelöscht ({count})",
|
||||
"loadError": "Zuletzt gelöscht konnte nicht geladen werden.",
|
||||
"emptyTitle": "Nichts unter Zuletzt gelöscht",
|
||||
"emptyDescription": "Was du löschst, wartet hier {days} Tage, bevor es endgültig entfernt wird.",
|
||||
"count": "Gelöschte Dokumente ({count})",
|
||||
"restoreAll": "Alle wiederherstellen",
|
||||
"restore": "Wiederherstellen",
|
||||
"purge": "Jetzt löschen",
|
||||
"deletedOn": "Gelöscht am {date}.",
|
||||
"removesIn": "Wird in {days} Tagen endgültig entfernt.",
|
||||
"removesSoon": "Wird innerhalb eines Tages endgültig entfernt.",
|
||||
"restored": "Dokument wiederhergestellt",
|
||||
"openRestored": "Öffnen",
|
||||
"restoreDuplicate": "Ein identisches Dokument ist schon gespeichert, deshalb kann diese Kopie nicht wiederhergestellt werden.",
|
||||
"restorePartial": "{failed} von {total} konnten nicht wiederhergestellt werden",
|
||||
"purged": "Dokument endgültig gelöscht",
|
||||
"purgeFailed": "Das Dokument konnte nicht gelöscht werden.",
|
||||
"purgeConfirmTitle": "Dieses Dokument endgültig löschen?",
|
||||
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12677,7 +12863,8 @@
|
||||
"entryHint": "Wenn {name} die Akte selbst führen möchte.",
|
||||
"methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
|
||||
"methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil: Messwerte, Medikamente, Dokumente und Notizen. Das Profil wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "In der Akte von {name} gespeichert"
|
||||
@@ -12732,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} hat einen Adressbucheintrag wiederhergestellt",
|
||||
"illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
|
||||
"medicationUpdate": "{name} hat ein Medikament geändert",
|
||||
"medicationCourseEnd": "{name} hat eine Medikamenteneinnahme beendet",
|
||||
"medicationDelete": "{name} hat ein Medikament gelöscht",
|
||||
"medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert",
|
||||
"medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt",
|
||||
@@ -12779,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen",
|
||||
"rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen",
|
||||
"rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
|
||||
"rejectedTrackedValueTypeCreate": "{name} wollte eine eigene Messgröße anlegen und wurde abgewiesen",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} wollte eine eigene Messgröße ändern und wurde abgewiesen",
|
||||
"rejectedMedicationLayout": "{name} wollte die Medikamentenliste neu anordnen und wurde abgewiesen",
|
||||
"deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
|
||||
"anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen",
|
||||
"customMetricCreate": "{name} hat eine eigene Messgröße angelegt",
|
||||
@@ -13088,7 +13279,8 @@
|
||||
"backgroundScan": "Wird im Hintergrund gelesen. Mit einem langsamen Modell kann das ein paar Minuten dauern.",
|
||||
"waitingForWorker": "Warte auf den Hintergrunddienst. Ändert sich das nicht, läuft der Worker auf dem Server vielleicht nicht.",
|
||||
"errorWorkerUnavailable": "Der Hintergrunddienst ist gerade nicht erreichbar. Versuch es gleich noch einmal oder bitte die Person, die den Server betreibt, ihn zu prüfen.",
|
||||
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen."
|
||||
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen.",
|
||||
"errorReaderBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Neues Symptom",
|
||||
|
||||
+339
-199
@@ -175,6 +175,9 @@
|
||||
"failed": "The label could not be read. Try a sharper photo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "The document reader is busy with other work. Try again in a minute."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow",
|
||||
"window": "analysed {from} to {to}, which can reach beyond the report period",
|
||||
"windowLine": "Analysed {from} to {to}. This window can reach beyond the report period."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} of {total} results were answered in a translation that has not been scientifically validated; treat them as orientation."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Invitation invalid",
|
||||
"description": "This invitation link is incomplete or was copied wrongly. Ask the person who invited you for a new link.",
|
||||
"toLogin": "Go to sign in"
|
||||
}
|
||||
},
|
||||
"deletionPending": "This account is scheduled for deletion on {date} and is locked until then. An admin of this server can undo the deletion before that date.",
|
||||
"deletionPendingNoDate": "This account is scheduled for deletion and is locked. An admin of this server can undo the deletion until it completes."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Back up your encryption key",
|
||||
"body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.",
|
||||
"action": "Open the step"
|
||||
},
|
||||
"quickAddSymptom": "Log symptom",
|
||||
"todayCardVisible": "Show the top card"
|
||||
"todayCardVisible": "Show the top card",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Save your recovery kit",
|
||||
"body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.",
|
||||
"action": "Open the kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.",
|
||||
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
|
||||
"errorReasoningOnly": "The model only reasoned and never wrote an answer. Try again, or choose a different model in your AI settings.",
|
||||
"errorDemo": "The Coach doesn't answer in the demo.",
|
||||
"errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.",
|
||||
"errorNoProviderAction": "Connect an AI provider",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
|
||||
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
|
||||
"noRecoveryWarning": "There is no recovery. If you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
|
||||
"detail": "Same shape as the weekly auto-backup. Some record types are not carried yet; the file's manifest names what is in it.",
|
||||
"detail": "A portable copy of your record for you to keep and read: values in plain JSON, documents as metadata only. It is not the weekly account copy this server keeps for rolling an account back, and it cannot recreate an account on another server. Some record types are not carried yet; the file's manifest names what is in it.",
|
||||
"contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} cycle days couldn't be written: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "JSON file",
|
||||
"title": "Measurements and mood (JSON)",
|
||||
"description": "Paste JSON or upload a .json file with measurements and mood entries.",
|
||||
"pasteLabel": "Paste JSON",
|
||||
"fileInputLabel": "Choose a JSON file to import",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Couldn't read that file.",
|
||||
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
|
||||
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries",
|
||||
"droppedLinks": "Imported without these tags, because this account has none by that name: {keys}",
|
||||
"keptLabels": "This account already has tags or groups with these keys under its own names, so those were kept: {keys}",
|
||||
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
|
||||
"resultEmpty": "That file carried no measurements and no mood entries."
|
||||
"resultEmpty": "That file carried no measurements and no mood entries.",
|
||||
"limitsHint": "Up to {mb} MB and up to {rows} entries in each list. It adds entries to your record; it does not restore a full backup."
|
||||
},
|
||||
"csv": {
|
||||
"title": "CSV file",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Export your data or import from a backup."
|
||||
"subtitle": "Export your data, or import measurements and records from other apps and files."
|
||||
},
|
||||
"about": {
|
||||
"title": "About this app",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "The token is shown once.",
|
||||
"scopeNote": "It can only add new readings to your own record. It cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.",
|
||||
"createFailed": "The token could not be created. Please try again.",
|
||||
"namePlaceholder": "Name, e.g. Home Assistant"
|
||||
"namePlaceholder": "Name, e.g. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Write readings to Apple Health",
|
||||
"createHint": "The HealthLog app on your iPhone then adds this token's readings to Apple Health. You can change this later in the token list below.",
|
||||
"toggleAria": "Write readings from {name} to Apple Health",
|
||||
"updateFailed": "The Apple Health setting could not be changed. Please try again.",
|
||||
"unattributed": "Readings sent before this setting existed can't be matched to a token, so they stay out of Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Workout tokens",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions.",
|
||||
"deleteAccountCta": "Delete account",
|
||||
"deleteAccountConfirmTitle": "Delete account permanently?",
|
||||
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
|
||||
"deleteAccountConfirmTitleGrace": "Delete your account?",
|
||||
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after. Copies in this server's backups age out after their retention period.",
|
||||
"deleteAccountSuccess": "Account deleted. Signing you out.",
|
||||
"deleteAccountFailed": "Account could not be deleted",
|
||||
"deleteAccountFinal": "Delete permanently",
|
||||
"deleteAccountFinalGrace": "Delete account",
|
||||
"saved": "Saved",
|
||||
"savingError": "Error saving",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Coach conversations: kept {days} days",
|
||||
"audit": "Security activity log: kept {days} days",
|
||||
"delivery": "Notification delivery log: kept {days} days",
|
||||
"unavailable": "Retention details are unavailable right now."
|
||||
"unavailable": "Retention details are unavailable right now.",
|
||||
"accountDeletion": "Deleting your account, a managed profile or all data: completed after {days} days",
|
||||
"accountDeletionImmediate": "Deleting your account, a managed profile or all data: completed immediately"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete or reset",
|
||||
"description": "Remove your account entirely, or wipe your health records while keeping the account.",
|
||||
"backupLag": "Deletion removes your data immediately. If this server keeps encrypted off-host backups, the copies of your data are deleted from that storage right after, or on the next nightly run if the storage is unreachable.",
|
||||
"openAdvanced": "Delete or reset"
|
||||
"openAdvanced": "Delete or reset",
|
||||
"immediateNotice": "Deletion removes your data immediately. If this server keeps encrypted off-host backups, the copies of your data are deleted from that storage right after, or on the next nightly run if the storage is unreachable. Copies in this server's backups age out after their retention period.",
|
||||
"graceNotice": "When you delete your account, it is locked immediately and erased after {days} days, together with every copy this server keeps of it, including encrypted off-host copies. Until then, an admin of this server can undo the deletion. Deleting all data works the same way: a copy of the record is kept for {days} days, so an admin can restore it. Copies in this server's backups age out after their retention period."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Privacy posture",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Off",
|
||||
"change": "Change in Modules"
|
||||
},
|
||||
"timezoneUseDevice": "Use this device's timezone ({zone})"
|
||||
"timezoneUseDevice": "Use this device's timezone ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "This deletes everything in your record: measurements, medications, mood entries, lab results, cycle entries, documents, AI conversations and every integration connection, together with everything derived from them and anything you deleted earlier. Share links you gave to a doctor stop working immediately. Your account stays, along with the credentials you sign in with and display settings such as language, timezone and units. A copy of your record is kept for {days} days, so an admin of this server can restore it until then. After that the deletion is final.",
|
||||
"deleteAccountCardDetailGrace": "Your account is locked right away and erased after {days} days. Until then, an admin of this server can undo the deletion.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Cannot force-logout yourself",
|
||||
"emptyTitle": "No users in this view",
|
||||
"emptyDescription": "There are no users matching the current filter.",
|
||||
"emptyResetFilter": "Show all users"
|
||||
"emptyResetFilter": "Show all users",
|
||||
"deletionPending": "Deletion on {date}",
|
||||
"deletionPendingNoDate": "Deletion pending",
|
||||
"undoDeletion": "Undo deletion",
|
||||
"undoDeletionTitle": "Undo the deletion of {name}?",
|
||||
"undoDeletionBody": "{name} asked to delete this account, and it is erased when the grace window ends. Undoing keeps the account and everything in it as it was. {name} can sign in again, but API tokens, share links and trusted devices stay revoked and have to be set up again.",
|
||||
"undoDeletionBodyGuardian": "A guardian deleted the managed profile {name}, and it is erased when the grace window ends. Undoing keeps the profile and everything in it as it was, and its guardians can reach it again. Its share links and tokens stay revoked and have to be set up again.",
|
||||
"undoDeletionDone": "Deletion undone for {name}",
|
||||
"undoDeletionFailed": "Could not undo the deletion"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invites",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"subtitle": "Weekly per-user data snapshots stored in PostgreSQL.",
|
||||
"description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob. Download decrypts the snapshot for the admin who asks; the ciphertext stays in the database.",
|
||||
"subtitle": "Account copies kept in this server's database, for rolling one account back on this server.",
|
||||
"description": "Each account has one weekly copy, replaced every Sunday, plus any copy uploaded here and the copy taken before \"Delete all data\". A copy brings one existing account back on this server. It cannot rebuild a lost server: that takes a database dump and the encryption keys. Sizes show the encrypted copy. Download decrypts it for the admin who asks; the ciphertext stays in the database.",
|
||||
"runNow": "Backup now",
|
||||
"runEnqueued": "Backup job enqueued",
|
||||
"runFailed": "Failed to enqueue backup",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Backups run automatically every Sunday at 03:00, or trigger one now to create the first snapshot.",
|
||||
"colUser": "User",
|
||||
"colType": "Type",
|
||||
"typeWeeklyAuto": "Weekly (auto)",
|
||||
"typeWeeklyAuto": "Weekly account rollback",
|
||||
"typeManual": "Manual",
|
||||
"colSize": "Size",
|
||||
"colCreatedAt": "Last snapshot",
|
||||
"colActions": "Actions",
|
||||
"retentionLabel": "Retention: {days} days",
|
||||
"scheduleStaleTitle": "The weekly backup has stopped",
|
||||
"scheduleStaleDescription": "The newest scheduled copy is {days} days old. A working weekly run never leaves one older than {threshold} days.",
|
||||
"scheduleFailedTitle": "The last scheduled backup run failed",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Validate a backup file and store it as a manual snapshot. Restoring is a separate step.",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Account setting",
|
||||
"restoreMissingTitle": "Restore refused: the file is incomplete",
|
||||
"restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.",
|
||||
"restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.",
|
||||
"restoreMissingRemedy": "Use one of this server's account copies or an off-host copy, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.",
|
||||
"restoreMissingDocuments": "Documents",
|
||||
"restoreMissingWorkouts": "Workouts",
|
||||
"restoreMissingMentalHealth": "Mental-health screeners",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "A restore of this account is already running. Wait for it to finish.",
|
||||
"restoreJobQueuedTitle": "Restore of {username} is waiting to start",
|
||||
"restoreJobQueuedNote": "It starts as soon as the background worker picks it up.",
|
||||
"restoreJobWaitingBackupNote": "Waiting for the backup that is running on the server. A restore never runs beside one, and it starts about a minute after the backup finishes.",
|
||||
"restoreJobWaitingBusyNote": "Waiting for a large job on the server to finish, such as an import or a nightly pass. It starts about a minute after that.",
|
||||
"restoreJobQueuedSince": "Queued at {when}.",
|
||||
"restoreJobRunningTitle": "Restoring {username}",
|
||||
"restoreJobRunningNote": "The account keeps showing its current data until the restore finishes. You can leave this page; the restore continues on the server.",
|
||||
"restoreJobResumed": "Started again after the server restarted.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "The file inside the backup belongs to a different account than the backup row.",
|
||||
"restoreFailureOwnerMissing": "The account this backup belongs to no longer exists.",
|
||||
"restoreFailureSectionMissing": "The file is incomplete: its manifest names a section it does not carry.",
|
||||
"restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use a disaster-recovery backup.",
|
||||
"restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use one of this server's account copies or an off-host copy instead.",
|
||||
"restoreFailureTimeBudget": "This backup is too large to restore within the background job's time limit.",
|
||||
"restoreFailureTransaction": "The database refused the restore part-way, so everything was rolled back. The server log names the error.",
|
||||
"restoreFailureCauseTimeout": "The restore ran longer than the database allowed, so everything was rolled back. If the database host was busy, start the restore again when it is quieter. The server log names the step.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)",
|
||||
"restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)",
|
||||
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)",
|
||||
"typePreWipe": "Before all data was deleted",
|
||||
"retentionNote": "The weekly copy is replaced every Sunday. A copy taken before \"Delete all data\" is deleted after {days} days. Uploaded copies stay until their account is erased.",
|
||||
"retentionNoteNoGrace": "The weekly copy is replaced every Sunday. Uploaded copies stay until their account is erased.",
|
||||
"database": {
|
||||
"title": "Database dump",
|
||||
"lastGood": "Last good dump {when}, {size}, {where}.",
|
||||
"whereBoth": "kept on this server and off-host",
|
||||
"whereLocal": "kept on this server",
|
||||
"whereOffhost": "off-host only",
|
||||
"none": "No database dump yet. The first one runs tonight.",
|
||||
"failedTitle": "The last database dump failed",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Database dumps are switched off (DB_BACKUP_ENABLED=false). Back the database up another way.",
|
||||
"noDirectoryOffhost": "This server cannot write to {dir}, so dumps go off-host only and none is kept here. Mount the backups volume.",
|
||||
"noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume.",
|
||||
"retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} nightly dumps are kept on this server, and the dump taken before the latest update stays until the next update.",
|
||||
"retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.",
|
||||
"retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.",
|
||||
"notPersistentOffhost": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory), so a dump written there would be lost at the next update. Dumps go off-host only: none is kept here and none is taken before a migration. Mount the backups volume.",
|
||||
"notPersistent": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory) and no off-host bucket is set, so no database dump is kept anywhere. Mount the backups volume.",
|
||||
"offhostFailedTitle": "The newest database dump did not reach the off-host bucket",
|
||||
"offhostLastUpload": "The newest dump in the bucket is from {when}.",
|
||||
"offhostNeverUploaded": "No database dump has reached the bucket yet."
|
||||
},
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger zone",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Hide the table",
|
||||
"columnsShowAll": "Show all {count} columns",
|
||||
"columnsShowWithRows": "Only columns with rows",
|
||||
"columnsNoneWithRows": "No encrypted column holds rows yet."
|
||||
"columnsNoneWithRows": "No encrypted column holds rows yet.",
|
||||
"backupsDumpLocalLine": "Key {key}: {count} database dumps on this server were written under it; it is needed until retention removes them.",
|
||||
"backupsDumpOffhostLine": "Key {key}: the newest database dump written under it went into the bucket on {when}; needed there until {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Key {key}: the newest database dump written under it went into the bucket on {when}. How long the bucket keeps database/ is unknown, so keep the key until you know that dump is gone."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Measurement table maintenance",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "This part of the page could not be shown",
|
||||
"description": "The rest of the page still works; try again or reload if it keeps failing."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Back up your encryption key",
|
||||
"description": "Without a copy of this key, your encrypted data and this server's backups cannot be read again.",
|
||||
"loading": "Loading the key backup status…",
|
||||
"loadError": "Could not load the key backup status.",
|
||||
"badgeDue": "To do",
|
||||
"badgeDone": "Backed up",
|
||||
"keyId": "Active key id",
|
||||
"fingerprint": "Fingerprint",
|
||||
"whatIsLost": "Keep a copy of the key somewhere outside this server, for example in a password manager. If the server is reinstalled with a different key, HealthLog refuses to start serving until the original key is back.",
|
||||
"fingerprintHint": "The fingerprint is the first 12 characters of the SHA-256 hash of the key. It identifies the key without revealing it, so you can compare it with your copy.",
|
||||
"checkLabel": "Check my copy",
|
||||
"checkButton": "Check",
|
||||
"checkNote": "The key you paste is compared with the active key and then discarded. It is never stored or logged.",
|
||||
"checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.",
|
||||
"checkMatches": "Your copy matches the active key.",
|
||||
"checkNoMatch": "Your copy does not match the active key. Check that you copied the whole value.",
|
||||
"checkFailed": "The check failed.",
|
||||
"confirmButton": "I have backed up this key",
|
||||
"confirmed": "Key backup confirmed.",
|
||||
"confirmFailed": "Could not save the confirmation.",
|
||||
"stale": "The server's key changed since this page loaded. Check the new key id and fingerprint, then confirm again.",
|
||||
"confirmedAt": "Confirmed on {date}.",
|
||||
"confirmedAtBy": "Confirmed on {date} by {who}.",
|
||||
"dueAgain": "A backup was confirmed for key {keyId}, but the server now uses another key. Back up the current one.",
|
||||
"tileLabel": "Encryption key backup",
|
||||
"tileDue": "Not confirmed",
|
||||
"tileDone": "Confirmed",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, select HealthLog, choose Edit and copy ENCRYPTION_KEY from the environment settings.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY from its environment variables."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog cannot open its data",
|
||||
"body": "The encryption key this server was started with is not the key its database was written with. To protect your data, HealthLog does not serve anything until this is fixed.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "The default level cannot be above the highest level.",
|
||||
"gapLabel": "Hours between card refreshes after a sync (Standard)",
|
||||
"gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Recovery kit",
|
||||
"description": "Every key a restore of this server needs, by id and fingerprint.",
|
||||
"intro": "Keep a copy of each key below outside this server, for example in a password manager. Without them, neither a database dump nor a backup copy can be read again, and HealthLog refuses to start if a key it needs is missing.",
|
||||
"loading": "Loading the recovery kit…",
|
||||
"loadError": "Could not load the recovery kit.",
|
||||
"badgeDue": "To do",
|
||||
"badgeDone": "Saved",
|
||||
"keyId": "Id",
|
||||
"fingerprint": "Fingerprint",
|
||||
"noFingerprint": "Not on this server",
|
||||
"kind": {
|
||||
"application": "Application key {id}",
|
||||
"backup": "Off-host backup key",
|
||||
"backupPrevious": "Previous off-host backup key",
|
||||
"token": "Token key"
|
||||
},
|
||||
"need": {
|
||||
"active": "Encrypts new data.",
|
||||
"configured": "Held by this server.",
|
||||
"storedCopies": "Needed by {count} stored copies.",
|
||||
"offhostUntil": "Needed by off-host copies until {date}.",
|
||||
"offhostUnknown": "Needed by off-host copies for as long as the bucket keeps them.",
|
||||
"backupActive": "Seals every new off-host copy.",
|
||||
"backupPrevious": "Opens off-host copies written before the last key change.",
|
||||
"token": "Signs sessions, tokens and share links. Without it everyone is signed out, but no data is lost.",
|
||||
"localDumps": "Needed by {count} database dumps on this server.",
|
||||
"offhostDumpsUntil": "Needed by off-host database dumps until {date}.",
|
||||
"offhostDumpsUnknown": "Needed by off-host database dumps for as long as the bucket keeps them."
|
||||
},
|
||||
"source": "Set in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Saved",
|
||||
"new": "Not saved yet",
|
||||
"notConfigured": "Not on this server",
|
||||
"unreadable": "Not a valid key"
|
||||
},
|
||||
"notConfiguredHint": "A backup still needs this key and this server does not hold it. Only a copy you keep elsewhere can read those backups. Confirm the kit once you have that copy.",
|
||||
"unreadableHint": "The variable is set but does not hold a valid key. Fix it before you rely on the off-host copies.",
|
||||
"fingerprintHint": "A fingerprint is the first 12 characters of the SHA-256 hash of a key. It identifies the key without revealing it, so you can compare it with your copy.",
|
||||
"whereTitle": "Where the keys are set",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "The keys are in the .env file next to docker-compose.yml: ENCRYPTION_KEY or ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY and, with off-host copies, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, select HealthLog and choose Edit. The keys are under HealthLog Configuration; in an app installed via YAML, they are ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY in the environment settings.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its environment variables."
|
||||
},
|
||||
"restoreTitle": "Restoring this server",
|
||||
"restoreBody": "Install HealthLog with the same keys, restore the newest database dump into an empty database, then start the app. The guide walks through each step.",
|
||||
"restoreLink": "Disaster recovery guide",
|
||||
"checkLabel": "Check my copy",
|
||||
"checkKeyLabel": "Key to check",
|
||||
"checkValueLabel": "Your copy of the key",
|
||||
"checkButton": "Check",
|
||||
"checkNote": "The key you paste is compared with the one this server holds and then discarded. It is never stored or logged.",
|
||||
"checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.",
|
||||
"checkMatches": "Your copy matches this key.",
|
||||
"checkNoMatch": "Your copy does not match this key. Check that you copied the whole value.",
|
||||
"checkFailed": "The check failed.",
|
||||
"confirmButton": "I have a copy of every key",
|
||||
"confirmed": "Recovery kit confirmed.",
|
||||
"confirmFailed": "Could not save the confirmation.",
|
||||
"stale": "The kit changed since this page loaded. Check the keys it lists now, then confirm again.",
|
||||
"confirmedAt": "Confirmed on {date}.",
|
||||
"confirmedAtBy": "Confirmed on {date} by {who}.",
|
||||
"dueAgain": "The kit was confirmed on {date}. It now also lists {keys}, which that confirmation did not cover. Save a copy of each and confirm again."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Recovery readiness",
|
||||
"description": "Whether this server could be put back after a loss, judged from every backup it makes.",
|
||||
"loading": "Checking the backups…",
|
||||
"loadError": "Could not load the recovery readiness.",
|
||||
"state": {
|
||||
"ok": "Ready",
|
||||
"attention": "Needs attention",
|
||||
"failing": "At risk"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Database dump",
|
||||
"weekly_copy": "Weekly account copies",
|
||||
"offhost_copies": "Off-host copies",
|
||||
"restore_drill": "Restore drill",
|
||||
"offhost_setup": "Off-host backup",
|
||||
"recovery_kit": "Recovery kit",
|
||||
"dump_retention": "Dumps in the bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Newest dump: {date}",
|
||||
"weekly_copy": "Oldest account copy: {date}",
|
||||
"offhost_copies": "Least recent off-host copy: {date}",
|
||||
"restore_drill": "Last drill: {date}",
|
||||
"recovery_kit": "Confirmed: {date}"
|
||||
},
|
||||
"dueBy": "Due by {date}",
|
||||
"fix": {
|
||||
"database_dump": "Open backups",
|
||||
"weekly_copy": "Open backups",
|
||||
"offhost_copies": "Open backups",
|
||||
"restore_drill": "Open backups",
|
||||
"offhost_setup": "Open backups",
|
||||
"recovery_kit": "Open the kit",
|
||||
"dump_retention": "Open backups"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "This version does not schedule a database dump.",
|
||||
"disabled": "The scheduled database dump is switched off, so no copy can rebuild this server. To turn it back on, remove DB_BACKUP_ENABLED or set it to true, then restart HealthLog.",
|
||||
"unreadable": "The status of the database dump could not be read.",
|
||||
"never_succeeded": "No database dump has succeeded yet, and the last attempt failed.",
|
||||
"never_ran": "No database dump has been written in the {hours} hours since dumps were switched on.",
|
||||
"first_pending": "The first database dump has not run yet.",
|
||||
"stale": "The newest database dump is {hours} hours old.",
|
||||
"last_failed": "The last database dump failed. The one before it is still recent.",
|
||||
"fresh": "The newest database dump is recent.",
|
||||
"nowhere_to_write": "The dump directory {directory} cannot be written to and no off-host bucket is set up, so no dump can be kept.",
|
||||
"not_persistent": "The dump directory {directory} is not on persistent storage and no off-host bucket is set up, so every dump would be lost at the next update. Mount the backups volume.",
|
||||
"fresh_offhost_only": "The newest database dump is recent and in the off-host bucket. None is kept on this server, because {directory} is not on persistent storage, and none is taken before a migration.",
|
||||
"offhost_failed": "The newest database dump is kept on this server, but its upload to the off-host bucket failed.",
|
||||
"offhost_stale": "No database dump has reached the off-host bucket for {hours} hours: the uploads keep failing. If this server is lost, there is no recent dump to rebuild it from.",
|
||||
"offhost_never": "No database dump has ever reached the off-host bucket: the upload fails. If this server is lost, there is no dump to rebuild it from."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "There are no accounts to copy yet.",
|
||||
"stale": "The oldest weekly copy is {days} days old. Accounts behind: {accounts}.",
|
||||
"never": "An account has had no weekly copy for {days} days. Accounts behind: {accounts}.",
|
||||
"first_pending": "The first weekly copy is made early on Sunday.",
|
||||
"fresh": "Every account has a copy from the last week."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "No off-host bucket is set up.",
|
||||
"stale": "Off-host copies are more than two nights behind. Accounts behind: {accounts}.",
|
||||
"first_pending": "The nightly off-host run has not reached any account yet.",
|
||||
"fresh": "Every account has a recent copy in the off-host bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "The drill reads the off-host bucket, which is not set up.",
|
||||
"not_run_yet": "The monthly restore drill has not run yet.",
|
||||
"failed": "The last restore drill failed: {failed} of {checked} copies could not be read back.",
|
||||
"failed_run": "The last restore drill could not finish.",
|
||||
"passed": "The last restore drill read back {checked} copies.",
|
||||
"nothing_checked": "The last restore drill stopped on its time limit before it read back a single copy, so it proved nothing."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Copies leave this server every night.",
|
||||
"no_admin": "There is no admin account yet.",
|
||||
"not_configured": "No off-host bucket is set up, so every copy lives on this server only.",
|
||||
"not_configured_grace": "No off-host bucket is set up yet, so every copy lives on this server only."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "A copy of every key a restore needs is kept.",
|
||||
"out_of_date": "The recovery kit now also lists {keys}, which the last confirmation did not cover. Nothing is broken: save a copy and confirm the kit again.",
|
||||
"no_admin": "There is no admin account yet.",
|
||||
"not_confirmed": "Nobody has confirmed a copy of the keys a restore needs: {keys}.",
|
||||
"not_confirmed_grace": "Save a copy of the keys a restore needs: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "No off-host bucket is set up.",
|
||||
"no_offhost_dumps": "No database dump goes to the bucket.",
|
||||
"missing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.",
|
||||
"configured": "The bucket removes dumps after {days} days.",
|
||||
"unknown": "The bucket does not say whether a rule removes old dumps. Check that a lifecycle rule covers database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "The database dump is recent again.",
|
||||
"weekly_copy": "Every account has a recent weekly copy again.",
|
||||
"offhost_copies": "Every account has a recent off-host copy again.",
|
||||
"restore_drill": "The restore drill passed again.",
|
||||
"offhost_setup": "Off-host copies are set up now.",
|
||||
"recovery_kit": "The recovery kit is confirmed again.",
|
||||
"dump_retention": "A lifecycle rule in the bucket covers the database dumps now."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Offline geolocation databases not loaded",
|
||||
"offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online lookup at {host}, so every login IP address leaves this server. To resolve them locally instead, download the GeoLite2-City and GeoLite2-ASN databases with a free MaxMind account, mount the directory into the container, and point GEOLITE2_DIR at it. Steps: {docsUrl}",
|
||||
"tlsPinChangedTitle": "TLS leaf certificate changed: {host}",
|
||||
"tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Account could not be erased: {username}",
|
||||
"accountErasureRefusedBody": "The deletion window of {username} has ended, but the account is the last active guardian of a managed profile, so it was not erased. It stays locked. Add another guardian to the profile, delete the profile, or undo the deletion in Administration, Users.",
|
||||
"accountErasureFailedBody": "The deletion window of {username} has ended, but erasing the account failed. It stays locked and is tried again every hour, after the other accounts due. The failing jobs card in Administration has the error."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Checkup reminders",
|
||||
"description": "Switch individual checkup reminders on or off."
|
||||
},
|
||||
"eventBackupHealth": "Backup health",
|
||||
"eventBackupHealthDesc": "When a backup stops, falls behind or recovers. Sent to admins only.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Backups need attention",
|
||||
"titleFailing": "Backups are at risk",
|
||||
"titleReminder": "Backups are still at risk",
|
||||
"titleRecovered": "Backups are back to normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,25 +11162,6 @@
|
||||
"who5": "Well-being check, 5 questions",
|
||||
"sci": "Sleep check, 8 questions"
|
||||
},
|
||||
"options": {
|
||||
"0": "Not at all",
|
||||
"1": "Several days",
|
||||
"2": "More than half the days",
|
||||
"3": "Nearly every day"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Not at all",
|
||||
"1": "Several days",
|
||||
"2": "More than half the days",
|
||||
"3": "Nearly every day"
|
||||
},
|
||||
"functionalTitle": "If you checked off any problems, how difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?",
|
||||
"functional": {
|
||||
"0": "Not difficult at all",
|
||||
"1": "Somewhat difficult",
|
||||
"2": "Very difficult",
|
||||
"3": "Extremely difficult"
|
||||
},
|
||||
"functionalOptionalHint": "Optional: you can submit without answering this question.",
|
||||
"result": {
|
||||
"title": "Your result",
|
||||
@@ -11015,45 +11211,6 @@
|
||||
"ifDanger": "If you are in immediate danger, call {emergency} now.",
|
||||
"resourcesTitle": "People who can help"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
},
|
||||
"who5": {
|
||||
"1": "I have felt cheerful and in good spirits",
|
||||
"2": "I have felt calm and relaxed",
|
||||
"3": "I have felt active and vigorous",
|
||||
"4": "I woke up feeling fresh and rested",
|
||||
"5": "My daily life has been filled with things that interest me"
|
||||
},
|
||||
"sci": {
|
||||
"1": "… how long does it take you to fall asleep?",
|
||||
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
|
||||
"3": "… how many nights a week do you have a problem with your sleep?",
|
||||
"4": "… how would you rate your sleep quality?",
|
||||
"5": "… affected your mood, energy, or relationships?",
|
||||
"6": "… affected your concentration, productivity, or ability to stay awake?",
|
||||
"7": "… troubled you in general?",
|
||||
"8": "… how long have you had a problem with your sleep?"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
@@ -11112,68 +11269,6 @@
|
||||
"who5": "A well-being score of 50 or below can be worth a closer look. If you like, the PHQ-9 check-in on this page looks at mood in more depth. This is a gentle suggestion, not a diagnosis.",
|
||||
"sci": "In the study behind this questionnaire, scores of 16 or below were associated with probable insomnia. If poor sleep keeps troubling you, talking to a health professional may help. This is not a diagnosis."
|
||||
},
|
||||
"validatedInEnglishNote": "This questionnaire is validated in English; the questions are shown in the original English wording.",
|
||||
"who5Options": {
|
||||
"0": "At no time",
|
||||
"1": "Some of the time",
|
||||
"2": "Less than half of the time",
|
||||
"3": "More than half of the time",
|
||||
"4": "Most of the time",
|
||||
"5": "All of the time"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
"phq9": {
|
||||
"period": "Over the last 2 weeks, how often have you been bothered by any of the following problems?"
|
||||
},
|
||||
"gad7": {
|
||||
"period": "Over the last 2 weeks, how often have you been bothered by the following problems?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "Over the last two weeks …"
|
||||
},
|
||||
"sci": {
|
||||
"night": "Thinking about a typical night in the last month …",
|
||||
"impact": "Thinking about the past month, to what extent has poor sleep …",
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Show details",
|
||||
"historyLoadError": "Your assessment history could not be loaded",
|
||||
"historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.",
|
||||
@@ -11223,6 +11318,13 @@
|
||||
"belowThreshold": "Your sleep over the last month was in the range where the study behind this questionnaire found insomnia likely.",
|
||||
"aboveThreshold": "Your sleep over the last month was outside the range that study linked with insomnia."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "This translation has not been scientifically validated; treat the result as orientation.",
|
||||
"showOriginal": "Show original English wording",
|
||||
"showTranslation": "Show translation",
|
||||
"resultUnvalidated": "Answered in a translation that has not been scientifically validated; treat the result as orientation.",
|
||||
"historyUnvalidated": "Answered in a translation that has not been scientifically validated"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11750,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Already stored. The existing document is highlighted.",
|
||||
"deleted": "Document deleted",
|
||||
"restoreFailed": "Couldn't restore the document."
|
||||
"restoreFailed": "Couldn't restore the document.",
|
||||
"deletedHint": "Deleted documents stay in Recently deleted for {days} days."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Loading more…",
|
||||
@@ -11795,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "The AI provider returned no text for this document."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Couldn't load this document.",
|
||||
@@ -11867,7 +11971,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"empty": "Nothing could be read from this document.",
|
||||
"unavailableBody": "Set up an AI provider to suggest a title, type, and date.",
|
||||
"unavailableLocalOcr": "Turn on local OCR in the AI settings to read images on this device.",
|
||||
"unavailableLocalOcr": "Turn on “Read scans on this device” under Settings → Modules → Labs to read images on this device.",
|
||||
"unavailableAction": "Open AI settings",
|
||||
"errorGeneric": "Couldn't read the document. Try again.",
|
||||
"errorOcr": "Couldn't read text from this image.",
|
||||
@@ -11879,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
|
||||
"errorFileType": "This file type can't be read.",
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
|
||||
"errorOutputLimit": "The AI provider reached its output limit before it returned a single value. Try a model that allows longer answers.",
|
||||
"errorLocalOcrDisabled": "Turn on “Read scans on this device” under Settings → Modules → Labs first.",
|
||||
"errorProvider": "No AI provider is set up for this.",
|
||||
"errorTextImageOnly": "Local OCR reads images only, not this file.",
|
||||
"errorConsent": "Reading documents with AI needs your consent first.",
|
||||
"errorUnavailable": "Reading documents with AI is not available here right now."
|
||||
"errorUnavailable": "Reading documents with AI is not available here right now.",
|
||||
"unavailableLocalOcrAction": "Open lab settings"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Summarise",
|
||||
@@ -11939,6 +12045,7 @@
|
||||
"extracting": "Extracting…",
|
||||
"extractHint": "Uses the text already read from this document. Extracted values wait for your review; nothing is saved automatically.",
|
||||
"extractEmpty": "No values could be extracted from this document.",
|
||||
"extractIncomplete": "Part of the document could not be read in full, so some values may be missing. Check the list against the document.",
|
||||
"failUnitRequired": "A numeric value needs a unit before it can be saved.",
|
||||
"failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
|
||||
"failGeneric": "This value could not be saved.",
|
||||
@@ -11986,6 +12093,7 @@
|
||||
"loadMore": "Load more",
|
||||
"stateImported": "In HealthLog",
|
||||
"stateDeleted": "Deleted in HealthLog",
|
||||
"stateInTrash": "In Recently deleted",
|
||||
"selectedCount": "{count} of up to {max} selected",
|
||||
"importAction": "Import {count}",
|
||||
"importing": "Importing, one document at a time…",
|
||||
@@ -11999,7 +12107,9 @@
|
||||
"importedLinked": "Imported and linked",
|
||||
"duplicate": "Already in HealthLog",
|
||||
"duplicateLinked": "Already in HealthLog, now linked",
|
||||
"deleted": "Deleted in HealthLog, left alone"
|
||||
"deleted": "Deleted in HealthLog, left alone",
|
||||
"restored": "Restored from Recently deleted",
|
||||
"restoredLinked": "Restored from Recently deleted and linked"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Document archives are not enabled on this server.",
|
||||
@@ -12043,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Delete this document?",
|
||||
"titleMany": "Delete {count} documents?",
|
||||
"body": "You can undo this right afterwards. After 30 days it is removed for good.",
|
||||
"body": "Deleted documents stay in Recently deleted for {days} days, then they are removed for good.",
|
||||
"action": "Delete"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Recently deleted",
|
||||
"subtitle": "Deleted documents stay here for {days} days, then they are removed for good.",
|
||||
"back": "Documents",
|
||||
"entryLabel": "Recently deleted ({count})",
|
||||
"loadError": "Couldn't load Recently deleted.",
|
||||
"emptyTitle": "Nothing in Recently deleted",
|
||||
"emptyDescription": "Documents you delete wait here for {days} days before they are removed for good.",
|
||||
"count": "Deleted documents ({count})",
|
||||
"restoreAll": "Restore all",
|
||||
"restore": "Restore",
|
||||
"purge": "Delete now",
|
||||
"deletedOn": "Deleted {date}.",
|
||||
"removesIn": "Removed for good in {days} days.",
|
||||
"removesSoon": "Removed for good within a day.",
|
||||
"restored": "Document restored",
|
||||
"openRestored": "Open",
|
||||
"restoreDuplicate": "An identical document is already stored, so this copy cannot be restored.",
|
||||
"restorePartial": "{failed} of {total} could not be restored",
|
||||
"purged": "Document deleted for good",
|
||||
"purgeFailed": "Couldn't delete the document.",
|
||||
"purgeConfirmTitle": "Delete this document for good?",
|
||||
"purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12729,7 +12863,8 @@
|
||||
"entryHint": "When {name} is ready to look after this record themselves.",
|
||||
"methodSso": "{name} signs in through single sign-on when they use the link.",
|
||||
"methodBoth": "{name} can set a password or sign in through single sign-on."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Everything recorded for {name} goes with the profile: readings, medications, documents and notes. The profile is locked right away and erased after {days} days. Until then, an admin of this server can undo the deletion."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record"
|
||||
@@ -12784,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} restored an address book entry",
|
||||
"illnessDayLogUpsert": "{name} recorded an illness day",
|
||||
"medicationUpdate": "{name} changed a medication",
|
||||
"medicationCourseEnd": "{name} ended a medication course",
|
||||
"medicationDelete": "{name} removed a medication",
|
||||
"medicationGlp1Update": "{name} changed a GLP-1 course",
|
||||
"medicationScheduleCreate": "{name} added a dosing schedule",
|
||||
@@ -12831,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} tried to change a dose and was refused",
|
||||
"rejectedDoseImport": "{name} tried to import doses and was refused",
|
||||
"rejectedTrackedValue": "{name} tried to log a tracked value and was refused",
|
||||
"rejectedTrackedValueTypeCreate": "{name} tried to add a tracked value type and was refused",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} tried to change a tracked value type and was refused",
|
||||
"rejectedMedicationLayout": "{name} tried to rearrange the medication list and was refused",
|
||||
"deniedDoseImport": "A dose import {name} started could not be queued",
|
||||
"anamnesisFactCreate": "{name} added a health background fact",
|
||||
"customMetricCreate": "{name} added a tracked value type",
|
||||
@@ -13140,7 +13279,8 @@
|
||||
"backgroundScan": "Reading in the background. With a slow model this can take a few minutes.",
|
||||
"waitingForWorker": "Waiting for the background worker. If this does not change, the server's worker may not be running.",
|
||||
"errorWorkerUnavailable": "The background worker is not available right now. Try again in a moment, or ask the server operator to check it.",
|
||||
"errorTimedOut": "Reading took longer than your AI response time allows. Try again, or raise the response time in the AI settings."
|
||||
"errorTimedOut": "Reading took longer than your AI response time allows. Try again, or raise the response time in the AI settings.",
|
||||
"errorReaderBusy": "The document reader is busy with other work. Try again in a minute."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "New symptom",
|
||||
|
||||
+339
-147
@@ -175,6 +175,9 @@
|
||||
"failed": "No se pudo leer la etiqueta. Prueba con una foto más nítida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos",
|
||||
"window": "analizado del {from} al {to}, puede ir más allá del periodo del informe",
|
||||
"windowLine": "Analizado del {from} al {to}. Este intervalo puede ir más allá del periodo del informe."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} de {total} resultados se respondieron con una traducción no validada científicamente; deben tomarse como orientación."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Invitación no válida",
|
||||
"description": "Este enlace de invitación está incompleto o se copió mal. Pide un enlace nuevo a la persona que te invitó.",
|
||||
"toLogin": "Ir a iniciar sesión"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Esta cuenta se eliminará el {date} y está bloqueada hasta entonces. Un administrador de este servidor puede deshacer la eliminación antes de esa fecha.",
|
||||
"deletionPendingNoDate": "Esta cuenta está programada para eliminarse y está bloqueada. Un administrador de este servidor puede deshacer la eliminación hasta que se complete."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Haz una copia de tu clave de cifrado",
|
||||
"body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.",
|
||||
"action": "Abrir el paso"
|
||||
},
|
||||
"quickAddSymptom": "Registrar síntoma",
|
||||
"todayCardVisible": "Mostrar la tarjeta superior"
|
||||
"todayCardVisible": "Mostrar la tarjeta superior",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Guarda tu kit de recuperación",
|
||||
"body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.",
|
||||
"action": "Abrir el kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mediciones",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.",
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.",
|
||||
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
|
||||
"errorReasoningOnly": "El modelo solo razonó y no llegó a escribir una respuesta. Inténtalo de nuevo o elige otro modelo en los ajustes de IA.",
|
||||
"errorDemo": "En la demo, el Coach no responde.",
|
||||
"errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.",
|
||||
"errorNoProviderAction": "Conectar un proveedor de IA",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
|
||||
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
|
||||
"detail": "El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
|
||||
"detail": "Una copia portátil de tu historial para guardarla y leerla: valores en JSON simple, documentos solo con sus metadatos. No es la copia semanal de la cuenta que este servidor guarda para restaurar una cuenta, y no puede recrear una cuenta en otro servidor. Algunos tipos de datos aún no se incluyen; el manifiesto del archivo indica qué contiene.",
|
||||
"contents": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} días de ciclo no se pudieron escribir: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Archivo JSON",
|
||||
"title": "Mediciones y estado de ánimo (JSON)",
|
||||
"description": "Pega JSON o sube un archivo .json con mediciones y entradas de ánimo.",
|
||||
"pasteLabel": "Pegar JSON",
|
||||
"fileInputLabel": "Elegir un archivo JSON para importar",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "No se pudo leer ese archivo.",
|
||||
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
|
||||
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas",
|
||||
"droppedLinks": "Importado sin estas etiquetas, porque esta cuenta no tiene ninguna con ese nombre: {keys}",
|
||||
"keptLabels": "Esta cuenta ya tiene etiquetas o grupos con estas claves y con su propio nombre, así que se han conservado: {keys}",
|
||||
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
|
||||
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
|
||||
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo.",
|
||||
"limitsHint": "Hasta {mb} MB y hasta {rows} entradas por lista. Añade entradas a tu historial; no restaura una copia de seguridad completa."
|
||||
},
|
||||
"csv": {
|
||||
"title": "Archivo CSV",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
|
||||
"subtitle": "Exporta tus datos o importa mediciones y registros de otras apps y archivos."
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de esta app",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "El token se muestra una sola vez.",
|
||||
"scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.",
|
||||
"createFailed": "No se pudo crear el token. Inténtalo de nuevo.",
|
||||
"namePlaceholder": "Nombre, p. ej. Home Assistant"
|
||||
"namePlaceholder": "Nombre, p. ej. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Escribir mediciones en Apple Health",
|
||||
"createHint": "La app HealthLog de tu iPhone añadirá entonces las mediciones de este token a Apple Health. Puedes cambiarlo más tarde en la lista de tokens de abajo.",
|
||||
"toggleAria": "Escribir las mediciones de {name} en Apple Health",
|
||||
"updateFailed": "No se pudo cambiar el ajuste de Apple Health. Inténtalo de nuevo.",
|
||||
"unattributed": "Las mediciones enviadas antes de que existiera este ajuste no se pueden asociar a ningún token, así que quedan fuera de Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Tokens de entrenamientos",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Elimina tu cuenta junto con las claves de acceso, el registro de auditoría y las sesiones.",
|
||||
"deleteAccountCta": "Eliminar cuenta",
|
||||
"deleteAccountConfirmTitle": "¿Eliminar la cuenta de forma permanente?",
|
||||
"deleteAccountConfirmDescription": "Tu cuenta, todos los datos de salud, las claves de acceso, las sesiones y las entradas de auditoría se eliminarán de forma permanente. Se cerrará tu sesión inmediatamente después.",
|
||||
"deleteAccountConfirmTitleGrace": "¿Eliminar tu cuenta?",
|
||||
"deleteAccountConfirmDescription": "Tu cuenta, todos los datos de salud, las claves de acceso, las sesiones y las entradas de auditoría se eliminarán de forma permanente. Se cerrará tu sesión inmediatamente después. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
|
||||
"deleteAccountSuccess": "Cuenta eliminada. Cerrando tu sesión.",
|
||||
"deleteAccountFailed": "No se pudo eliminar la cuenta",
|
||||
"deleteAccountFinal": "Eliminar permanentemente",
|
||||
"deleteAccountFinalGrace": "Eliminar cuenta",
|
||||
"saved": "Guardado",
|
||||
"savingError": "Error al guardar",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Conversaciones con el asistente: {days} días",
|
||||
"audit": "Registro de seguridad: {days} días",
|
||||
"delivery": "Registro de envío de notificaciones: {days} días",
|
||||
"unavailable": "Los detalles de retención no están disponibles en este momento."
|
||||
"unavailable": "Los detalles de retención no están disponibles en este momento.",
|
||||
"accountDeletion": "Eliminar tu cuenta, un perfil gestionado o todos los datos: se completa al cabo de {days} días",
|
||||
"accountDeletionImmediate": "Eliminar tu cuenta, un perfil gestionado o todos los datos: se completa de inmediato"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar o restablecer",
|
||||
"description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.",
|
||||
"backupLag": "La eliminación borra tus datos de inmediato. Si este servidor guarda copias de seguridad cifradas externas, las copias de tus datos se borran de ese almacenamiento justo después, o en la siguiente ejecución nocturna si no está disponible.",
|
||||
"openAdvanced": "Eliminar o restablecer"
|
||||
"openAdvanced": "Eliminar o restablecer",
|
||||
"immediateNotice": "La eliminación borra tus datos de inmediato. Si este servidor guarda copias de seguridad cifradas externas, las copias de tus datos se borran de ese almacenamiento justo después, o en la siguiente ejecución nocturna si no está disponible. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
|
||||
"graceNotice": "Cuando eliminas tu cuenta, se bloquea de inmediato y se borra al cabo de {days} días, junto con todas las copias que este servidor guarda de ella, incluidas las copias cifradas fuera del servidor. Hasta entonces, un administrador de este servidor puede deshacer la eliminación. Eliminar todos los datos funciona igual: se guarda una copia del historial durante {days} días, para que un administrador pueda restaurarla. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Configuración de privacidad",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Desactivado",
|
||||
"change": "Cambiar en Módulos"
|
||||
},
|
||||
"timezoneUseDevice": "Usar la zona horaria de este dispositivo ({zone})"
|
||||
"timezoneUseDevice": "Usar la zona horaria de este dispositivo ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Esto elimina todo lo que hay en tu historial: mediciones, medicamentos, entradas de ánimo, resultados de laboratorio, entradas de ciclo, documentos, conversaciones con la IA y todas las conexiones de integración, junto con todo lo derivado de ello y todo lo que eliminaste antes. Los enlaces compartidos que diste a una consulta dejan de funcionar de inmediato. Tu cuenta se mantiene, junto con las credenciales con las que inicias sesión y los ajustes de presentación como el idioma, la zona horaria y las unidades. Se guarda una copia de tu historial durante {days} días, para que un administrador de este servidor pueda restaurarla hasta entonces. Después, la eliminación es definitiva.",
|
||||
"deleteAccountCardDetailGrace": "Tu cuenta se bloquea de inmediato y se borra al cabo de {days} días. Hasta entonces, un administrador de este servidor puede deshacer la eliminación.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "No puedes forzar tu propio cierre de sesión",
|
||||
"emptyTitle": "No hay usuarios en esta vista",
|
||||
"emptyDescription": "No hay usuarios que coincidan con el filtro actual.",
|
||||
"emptyResetFilter": "Mostrar todos los usuarios"
|
||||
"emptyResetFilter": "Mostrar todos los usuarios",
|
||||
"deletionPending": "Eliminación el {date}",
|
||||
"deletionPendingNoDate": "Eliminación pendiente",
|
||||
"undoDeletion": "Deshacer eliminación",
|
||||
"undoDeletionTitle": "¿Deshacer la eliminación de {name}?",
|
||||
"undoDeletionBody": "{name} pidió eliminar esta cuenta, y se borra cuando termina el plazo de gracia. Deshacerlo conserva la cuenta y todo su contenido tal como estaba. {name} puede volver a iniciar sesión, pero los tokens de API, los enlaces compartidos y los dispositivos de confianza siguen revocados y hay que configurarlos de nuevo.",
|
||||
"undoDeletionBodyGuardian": "Una persona responsable eliminó el perfil gestionado {name}, y se borra cuando termina el plazo de gracia. Deshacerlo conserva el perfil y todo su contenido tal como estaba, y las personas responsables vuelven a tener acceso. Sus enlaces compartidos y tokens siguen revocados y hay que configurarlos de nuevo.",
|
||||
"undoDeletionDone": "Eliminación de {name} deshecha",
|
||||
"undoDeletionFailed": "No se pudo deshacer la eliminación"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitaciones",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
"subtitle": "Instantáneas de datos semanales por usuario almacenadas en PostgreSQL.",
|
||||
"description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado. Descargar descifra la instantánea para el administrador que la pide; el texto cifrado permanece en la base de datos.",
|
||||
"subtitle": "Copias de cuentas en la base de datos de este servidor, para restaurar una cuenta aquí.",
|
||||
"description": "Cada cuenta tiene una copia semanal, que se reemplaza cada domingo, además de las copias subidas aquí y la copia que se toma antes de «Eliminar todos los datos». Una copia restaura una cuenta existente en este servidor. No puede reconstruir un servidor perdido: para eso hacen falta un volcado de la base de datos y las claves de cifrado. Los tamaños muestran la copia cifrada. La descarga la descifra para el administrador que la pide; el texto cifrado se queda en la base de datos.",
|
||||
"runNow": "Hacer copia ahora",
|
||||
"runEnqueued": "Tarea de copia de seguridad en cola",
|
||||
"runFailed": "No se pudo poner en cola la copia de seguridad",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Las copias de seguridad se ejecutan automáticamente cada domingo a las 03:00, o inicia una ahora para crear la primera instantánea.",
|
||||
"colUser": "Usuario",
|
||||
"colType": "Tipo",
|
||||
"typeWeeklyAuto": "Semanal (auto)",
|
||||
"typeWeeklyAuto": "Copia semanal de la cuenta",
|
||||
"typeManual": "Manual",
|
||||
"colSize": "Tamaño",
|
||||
"colCreatedAt": "Última instantánea",
|
||||
"colActions": "Acciones",
|
||||
"retentionLabel": "Conservación: {days} días",
|
||||
"scheduleStaleTitle": "La copia de seguridad semanal se ha detenido",
|
||||
"scheduleStaleDescription": "La copia programada más reciente tiene {days} días. Una ejecución semanal que funciona nunca deja ninguna con más de {threshold} días.",
|
||||
"scheduleFailedTitle": "La última ejecución programada de la copia de seguridad falló",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "La descarga falló",
|
||||
"downloadStarted": "Descarga iniciada",
|
||||
"uploadTitle": "Subir copia de seguridad",
|
||||
"uploadDescription": "Valida un archivo de copia de seguridad y guárdalo como instantánea manual. La restauración es un paso aparte.",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.",
|
||||
"uploadButton": "Subir archivo",
|
||||
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
|
||||
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Ajuste de la cuenta",
|
||||
"restoreMissingTitle": "Restauración rechazada: el archivo está incompleto",
|
||||
"restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.",
|
||||
"restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.",
|
||||
"restoreMissingRemedy": "Usa una de las copias de cuenta de este servidor o una copia fuera del servidor, o exporta de nuevo la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.",
|
||||
"restoreMissingDocuments": "Documentos",
|
||||
"restoreMissingWorkouts": "Entrenamientos",
|
||||
"restoreMissingMentalHealth": "Cuestionarios de salud mental",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Ya hay una restauración en curso para esta cuenta. Espera a que termine.",
|
||||
"restoreJobQueuedTitle": "La restauración de {username} está esperando para empezar",
|
||||
"restoreJobQueuedNote": "Empieza en cuanto el proceso en segundo plano la recoja.",
|
||||
"restoreJobWaitingBackupNote": "Esperando a la copia de seguridad que se está ejecutando en el servidor. Una restauración nunca se ejecuta a la vez que una copia, y empieza más o menos un minuto después de que termine.",
|
||||
"restoreJobWaitingBusyNote": "Esperando a que termine un trabajo grande en el servidor, como una importación o un proceso nocturno. Empieza más o menos un minuto después.",
|
||||
"restoreJobQueuedSince": "En cola desde {when}.",
|
||||
"restoreJobRunningTitle": "Restaurando {username}",
|
||||
"restoreJobRunningNote": "La cuenta sigue mostrando sus datos actuales hasta que termine la restauración. Puedes salir de esta página; la restauración continúa en el servidor.",
|
||||
"restoreJobResumed": "Iniciada de nuevo tras reiniciarse el servidor.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "El archivo de la copia pertenece a otra cuenta que la fila de la copia.",
|
||||
"restoreFailureOwnerMissing": "La cuenta a la que pertenece esta copia ya no existe.",
|
||||
"restoreFailureSectionMissing": "El archivo está incompleto: su manifiesto nombra una sección que no contiene.",
|
||||
"restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa una copia de recuperación ante desastres.",
|
||||
"restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa en su lugar una de las copias de cuenta de este servidor o una copia fuera del servidor.",
|
||||
"restoreFailureTimeBudget": "Esta copia es demasiado grande para restaurarse dentro del límite de tiempo del proceso en segundo plano.",
|
||||
"restoreFailureTransaction": "La base de datos rechazó la restauración a mitad, así que se deshizo todo. El registro del servidor indica el error.",
|
||||
"restoreFailureCauseTimeout": "La restauración tardó más de lo que permite la base de datos, así que se deshizo todo. Si el servidor de la base de datos estaba ocupado, vuelve a iniciarla cuando esté más tranquilo. El registro del servidor indica el paso.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)",
|
||||
"restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)",
|
||||
"typePreWipe": "Antes de eliminar todos los datos",
|
||||
"retentionNote": "La copia semanal se reemplaza cada domingo. Una copia tomada antes de «Eliminar todos los datos» se elimina al cabo de {days} días. Las copias subidas se quedan hasta que se borra su cuenta.",
|
||||
"retentionNoteNoGrace": "La copia semanal se reemplaza cada domingo. Las copias subidas se quedan hasta que se borra su cuenta.",
|
||||
"database": {
|
||||
"title": "Volcado de la base de datos",
|
||||
"lastGood": "Último volcado correcto {when}, {size}, {where}.",
|
||||
"whereBoth": "guardado en este servidor y fuera de él",
|
||||
"whereLocal": "guardado en este servidor",
|
||||
"whereOffhost": "solo fuera del servidor",
|
||||
"none": "Aún no hay ningún volcado de la base de datos. El primero se hace esta noche.",
|
||||
"failedTitle": "El último volcado de la base de datos falló",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Los volcados de la base de datos están desactivados (DB_BACKUP_ENABLED=false). Haz la copia de la base de datos de otra forma.",
|
||||
"noDirectoryOffhost": "Este servidor no puede escribir en {dir}, así que los volcados solo se guardan fuera y aquí no queda ninguno. Monta el volumen de copias.",
|
||||
"noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias.",
|
||||
"retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} volcados nocturnos más recientes, y el volcado tomado antes de la última actualización se queda hasta la siguiente.",
|
||||
"retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.",
|
||||
"retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.",
|
||||
"notPersistentOffhost": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria), así que un volcado guardado ahí se perdería en la próxima actualización. Los volcados solo se guardan fuera: aquí no queda ninguno y no se hace ninguno antes de una migración. Monta el volumen de copias.",
|
||||
"notPersistent": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria) y no hay un bucket externo configurado, así que no se conserva ningún volcado de la base de datos en ningún sitio. Monta el volumen de copias.",
|
||||
"offhostFailedTitle": "El volcado más reciente de la base de datos no llegó al bucket externo",
|
||||
"offhostLastUpload": "El volcado más reciente del bucket es del {when}.",
|
||||
"offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Ocultar la tabla",
|
||||
"columnsShowAll": "Mostrar las {count} columnas",
|
||||
"columnsShowWithRows": "Solo columnas con filas",
|
||||
"columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía."
|
||||
"columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía.",
|
||||
"backupsDumpLocalLine": "Clave {key}: {count} volcados de la base de datos en este servidor se escribieron con ella; se necesita hasta que la retención los elimine.",
|
||||
"backupsDumpOffhostLine": "Clave {key}: el volcado de la base de datos más reciente escrito con ella entró en el bucket el {when}; se necesita allí hasta el {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Clave {key}: el volcado de la base de datos más reciente escrito con ella entró en el bucket el {when}. No se sabe cuánto tiempo conserva el bucket database/, así que conserva la clave hasta saber que ese volcado ya no está."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Mantenimiento de la tabla de mediciones",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Esta parte de la página no se pudo mostrar",
|
||||
"description": "El resto de la página sigue funcionando; inténtalo de nuevo o recarga si el fallo continúa."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Haz una copia de tu clave de cifrado",
|
||||
"description": "Sin una copia de esta clave, tus datos cifrados y las copias de este servidor no se podrán volver a leer.",
|
||||
"loading": "Cargando el estado de la copia de la clave…",
|
||||
"loadError": "No se pudo cargar el estado de la copia de la clave.",
|
||||
"badgeDue": "Pendiente",
|
||||
"badgeDone": "Guardada",
|
||||
"keyId": "ID de la clave activa",
|
||||
"fingerprint": "Huella",
|
||||
"whatIsLost": "Guarda una copia de la clave fuera de este servidor, por ejemplo en un gestor de contraseñas. Si el servidor se reinstala con otra clave, HealthLog no vuelve a funcionar hasta que la clave original esté de vuelta.",
|
||||
"fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de la clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.",
|
||||
"checkLabel": "Comprobar mi copia",
|
||||
"checkButton": "Comprobar",
|
||||
"checkNote": "La clave que pegues se compara con la clave activa y después se descarta. Nunca se guarda ni se registra.",
|
||||
"checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.",
|
||||
"checkMatches": "Tu copia coincide con la clave activa.",
|
||||
"checkNoMatch": "Tu copia no coincide con la clave activa. Comprueba que copiaste el valor completo.",
|
||||
"checkFailed": "La comprobación ha fallado.",
|
||||
"confirmButton": "He guardado esta clave",
|
||||
"confirmed": "Copia de la clave confirmada.",
|
||||
"confirmFailed": "No se pudo guardar la confirmación.",
|
||||
"stale": "La clave del servidor cambió desde que se cargó esta página. Revisa el nuevo ID y la huella, y vuelve a confirmar.",
|
||||
"confirmedAt": "Confirmada el {date}.",
|
||||
"confirmedAtBy": "Confirmada el {date} por {who}.",
|
||||
"dueAgain": "Se confirmó una copia de la clave {keyId}, pero el servidor usa ahora otra clave. Guarda una copia de la actual.",
|
||||
"tileLabel": "Copia de la clave de cifrado",
|
||||
"tileDue": "Sin confirmar",
|
||||
"tileDone": "Confirmada",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog, elige Editar y copia ENCRYPTION_KEY de las variables de entorno.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY de sus variables de entorno."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog no puede abrir sus datos",
|
||||
"body": "La clave de cifrado con la que se inició este servidor no es la clave con la que se escribió su base de datos. Para proteger tus datos, HealthLog no sirve nada hasta que se corrija.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.",
|
||||
"gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)",
|
||||
"gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de recuperación",
|
||||
"description": "Cada clave que necesita una restauración de este servidor, con su ID y su huella.",
|
||||
"intro": "Guarda una copia de cada clave de abajo fuera de este servidor, por ejemplo en un gestor de contraseñas. Sin ellas no se puede volver a leer ni un volcado de la base de datos ni una copia de seguridad, y HealthLog se niega a arrancar si falta una clave que necesita.",
|
||||
"loading": "Cargando el kit de recuperación…",
|
||||
"loadError": "No se pudo cargar el kit de recuperación.",
|
||||
"badgeDue": "Pendiente",
|
||||
"badgeDone": "Guardado",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Huella",
|
||||
"noFingerprint": "No está en este servidor",
|
||||
"kind": {
|
||||
"application": "Clave de aplicación {id}",
|
||||
"backup": "Clave de las copias fuera del host",
|
||||
"backupPrevious": "Clave anterior de las copias fuera del host",
|
||||
"token": "Clave de tokens"
|
||||
},
|
||||
"need": {
|
||||
"active": "Cifra los datos nuevos.",
|
||||
"configured": "Está en este servidor.",
|
||||
"storedCopies": "La necesitan {count} copias guardadas.",
|
||||
"offhostUntil": "La necesitan las copias fuera del host hasta el {date}.",
|
||||
"offhostUnknown": "La necesitan las copias fuera del host mientras el bucket las conserve.",
|
||||
"backupActive": "Sella cada nueva copia fuera del host.",
|
||||
"backupPrevious": "Abre las copias fuera del host escritas antes del último cambio de clave.",
|
||||
"token": "Firma sesiones, tokens y enlaces para compartir. Sin ella se cierra la sesión de todo el mundo, pero no se pierden datos.",
|
||||
"localDumps": "La necesitan {count} volcados de la base de datos en este servidor.",
|
||||
"offhostDumpsUntil": "La necesitan los volcados de la base de datos fuera del host hasta el {date}.",
|
||||
"offhostDumpsUnknown": "La necesitan los volcados de la base de datos fuera del host mientras el bucket los conserve."
|
||||
},
|
||||
"source": "Definida en {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Guardada",
|
||||
"new": "Aún sin guardar",
|
||||
"notConfigured": "No está en este servidor",
|
||||
"unreadable": "No es una clave válida"
|
||||
},
|
||||
"notConfiguredHint": "Una copia de seguridad aún necesita esta clave y este servidor no la tiene. Solo una copia que guardes en otro lugar puede leer esas copias de seguridad. Confirma el kit cuando tengas esa copia.",
|
||||
"unreadableHint": "La variable está definida pero no contiene una clave válida. Corrígela antes de confiar en las copias fuera del host.",
|
||||
"fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de una clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.",
|
||||
"whereTitle": "Dónde se definen las claves",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Las claves están en el archivo .env junto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY y, con copias fuera del host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog y elige Edit. Las claves están en HealthLog Configuration; en una app instalada con YAML, son ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY en los ajustes de entorno.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus variables de entorno."
|
||||
},
|
||||
"restoreTitle": "Restaurar este servidor",
|
||||
"restoreBody": "Instala HealthLog con las mismas claves, restaura el volcado de la base de datos más reciente en una base de datos vacía y luego arranca la app. La guía explica cada paso.",
|
||||
"restoreLink": "Guía de recuperación ante desastres",
|
||||
"checkLabel": "Comprobar mi copia",
|
||||
"checkKeyLabel": "Clave que comprobar",
|
||||
"checkValueLabel": "Tu copia de la clave",
|
||||
"checkButton": "Comprobar",
|
||||
"checkNote": "La clave que pegues se compara con la que tiene este servidor y después se descarta. Nunca se guarda ni se registra.",
|
||||
"checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.",
|
||||
"checkMatches": "Tu copia coincide con esta clave.",
|
||||
"checkNoMatch": "Tu copia no coincide con esta clave. Comprueba que copiaste el valor completo.",
|
||||
"checkFailed": "La comprobación ha fallado.",
|
||||
"confirmButton": "Tengo una copia de cada clave",
|
||||
"confirmed": "Kit de recuperación confirmado.",
|
||||
"confirmFailed": "No se pudo guardar la confirmación.",
|
||||
"stale": "El kit cambió desde que se cargó esta página. Revisa las claves que muestra ahora y vuelve a confirmar.",
|
||||
"confirmedAt": "Confirmado el {date}.",
|
||||
"confirmedAtBy": "Confirmado el {date} por {who}.",
|
||||
"dueAgain": "El kit se confirmó el {date}. Ahora también incluye {keys}, que esa confirmación no cubría. Guarda una copia de cada una y vuelve a confirmar."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Preparación para la recuperación",
|
||||
"description": "Si este servidor se podría recuperar tras una pérdida, a juzgar por cada copia de seguridad que hace.",
|
||||
"loading": "Comprobando las copias de seguridad…",
|
||||
"loadError": "No se pudo cargar la preparación para la recuperación.",
|
||||
"state": {
|
||||
"ok": "Listo",
|
||||
"attention": "Necesita atención",
|
||||
"failing": "En riesgo"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Volcado de la base de datos",
|
||||
"weekly_copy": "Copias semanales de las cuentas",
|
||||
"offhost_copies": "Copias fuera del host",
|
||||
"restore_drill": "Simulacro de restauración",
|
||||
"offhost_setup": "Copia fuera del host",
|
||||
"recovery_kit": "Kit de recuperación",
|
||||
"dump_retention": "Volcados en el bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Volcado más reciente: {date}",
|
||||
"weekly_copy": "Copia de cuenta más antigua: {date}",
|
||||
"offhost_copies": "Copia fuera del host más antigua: {date}",
|
||||
"restore_drill": "Último simulacro: {date}",
|
||||
"recovery_kit": "Confirmado: {date}"
|
||||
},
|
||||
"dueBy": "Vence el {date}",
|
||||
"fix": {
|
||||
"database_dump": "Abrir copias de seguridad",
|
||||
"weekly_copy": "Abrir copias de seguridad",
|
||||
"offhost_copies": "Abrir copias de seguridad",
|
||||
"restore_drill": "Abrir copias de seguridad",
|
||||
"offhost_setup": "Abrir copias de seguridad",
|
||||
"recovery_kit": "Abrir el kit",
|
||||
"dump_retention": "Abrir copias de seguridad"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Esta versión no programa un volcado de la base de datos.",
|
||||
"disabled": "El volcado programado de la base de datos está desactivado, así que ninguna copia puede reconstruir este servidor. Para volver a activarlo, quita DB_BACKUP_ENABLED o ponlo en true y reinicia HealthLog.",
|
||||
"unreadable": "No se pudo leer el estado del volcado de la base de datos.",
|
||||
"never_succeeded": "Ningún volcado de la base de datos ha salido bien todavía, y el último intento falló.",
|
||||
"never_ran": "No se ha escrito ningún volcado de la base de datos en las {hours} horas transcurridas desde que se activaron los volcados.",
|
||||
"first_pending": "El primer volcado de la base de datos aún no se ha ejecutado.",
|
||||
"stale": "El volcado de la base de datos más reciente tiene {hours} horas.",
|
||||
"last_failed": "El último volcado de la base de datos falló. El anterior sigue siendo reciente.",
|
||||
"fresh": "El último volcado de la base de datos es reciente.",
|
||||
"nowhere_to_write": "No se puede escribir en el directorio de volcados {directory} y no hay ningún bucket externo configurado, así que no se puede guardar ningún volcado.",
|
||||
"not_persistent": "El directorio de volcados {directory} no está en almacenamiento persistente y no hay un bucket externo configurado, así que cada volcado se perdería en la próxima actualización. Monta el volumen de copias.",
|
||||
"fresh_offhost_only": "El volcado más reciente de la base de datos es actual y está en el bucket externo. En este servidor no se conserva ninguno, porque {directory} no está en almacenamiento persistente, y no se hace ninguno antes de una migración.",
|
||||
"offhost_failed": "El volcado más reciente de la base de datos se guarda en este servidor, pero la subida al bucket externo falló.",
|
||||
"offhost_stale": "Ningún volcado de la base de datos ha llegado al bucket externo en {hours} horas: las subidas siguen fallando. Si se pierde este servidor, no hay un volcado reciente para reconstruirlo.",
|
||||
"offhost_never": "Ningún volcado de la base de datos ha llegado nunca al bucket externo: la subida falla. Si se pierde este servidor, no hay ningún volcado para reconstruirlo."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Todavía no hay cuentas que copiar.",
|
||||
"stale": "La copia semanal más antigua tiene {days} días. Cuentas con retraso: {accounts}.",
|
||||
"never": "Una cuenta lleva {days} días sin copia semanal. Cuentas con retraso: {accounts}.",
|
||||
"first_pending": "La primera copia semanal se hace el domingo de madrugada.",
|
||||
"fresh": "Cada cuenta tiene una copia de la última semana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "No hay ningún bucket fuera del host configurado.",
|
||||
"stale": "Las copias fuera del host llevan más de dos noches de retraso. Cuentas con retraso: {accounts}.",
|
||||
"first_pending": "La ejecución nocturna fuera del host aún no ha llegado a ninguna cuenta.",
|
||||
"fresh": "Cada cuenta tiene una copia reciente en el bucket fuera del host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "El simulacro lee el bucket fuera del host, que no está configurado.",
|
||||
"not_run_yet": "El simulacro de restauración mensual aún no se ha ejecutado.",
|
||||
"failed": "El último simulacro de restauración falló: no se pudieron volver a leer {failed} de {checked} copias.",
|
||||
"failed_run": "El último simulacro de restauración no pudo terminar.",
|
||||
"passed": "El último simulacro de restauración volvió a leer {checked} copias.",
|
||||
"nothing_checked": "El último simulacro de restauración se detuvo por su límite de tiempo antes de leer una sola copia, así que no demostró nada."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Las copias salen de este servidor cada noche.",
|
||||
"no_admin": "Todavía no hay ninguna cuenta de administrador.",
|
||||
"not_configured": "No hay ningún bucket fuera del host configurado, así que cada copia está solo en este servidor.",
|
||||
"not_configured_grace": "Aún no hay ningún bucket fuera del host configurado, así que cada copia está solo en este servidor."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Se guarda una copia de cada clave que necesita una restauración.",
|
||||
"out_of_date": "El kit de recuperación ahora también incluye {keys}, que la última confirmación no cubría. No hay nada roto: guarda una copia y vuelve a confirmar el kit.",
|
||||
"no_admin": "Todavía no hay ninguna cuenta de administrador.",
|
||||
"not_confirmed": "Nadie ha confirmado una copia de las claves que necesita una restauración: {keys}.",
|
||||
"not_confirmed_grace": "Guarda una copia de las claves que necesita una restauración: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "No hay ningún bucket externo configurado.",
|
||||
"no_offhost_dumps": "Ningún volcado de la base de datos va al bucket.",
|
||||
"missing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.",
|
||||
"configured": "El bucket borra los volcados a los {days} días.",
|
||||
"unknown": "El bucket no indica si alguna regla borra los volcados antiguos. Comprueba que una regla de ciclo de vida cubra database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "El volcado de la base de datos vuelve a ser reciente.",
|
||||
"weekly_copy": "Cada cuenta vuelve a tener una copia semanal reciente.",
|
||||
"offhost_copies": "Cada cuenta vuelve a tener una copia fuera del host reciente.",
|
||||
"restore_drill": "El simulacro de restauración vuelve a superarse.",
|
||||
"offhost_setup": "Las copias fuera del host ya están configuradas.",
|
||||
"recovery_kit": "El kit de recuperación vuelve a estar confirmado.",
|
||||
"dump_retention": "Ahora una regla de ciclo de vida del bucket cubre los volcados de la base de datos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas",
|
||||
"offlineGeoUnavailableBody": "HealthLog resuelve las ubicaciones de inicio de sesión mediante la consulta en línea a {host}, por lo que cada IP de inicio de sesión sale de este servidor. Para resolverlas localmente, descarga las bases GeoLite2-City y GeoLite2-ASN con una cuenta gratuita de MaxMind, monta el directorio en el contenedor y apunta GEOLITE2_DIR a él. Pasos: {docsUrl}",
|
||||
"tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}",
|
||||
"tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "No se pudo borrar la cuenta: {username}",
|
||||
"accountErasureRefusedBody": "El plazo de eliminación de {username} ha terminado, pero la cuenta es la última persona responsable activa de un perfil gestionado, así que no se ha borrado. Sigue bloqueada. Añade otra persona responsable al perfil, elimina el perfil o deshaz la eliminación en Administration, Usuarios.",
|
||||
"accountErasureFailedBody": "El plazo de eliminación de {username} ha terminado, pero no se pudo borrar la cuenta. Sigue bloqueada y se vuelve a intentar cada hora, después de las demás cuentas pendientes. El error aparece en la tarjeta de tareas fallidas de Administration."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Recordatorios de chequeos",
|
||||
"description": "Activa o desactiva recordatorios de chequeos concretos."
|
||||
},
|
||||
"eventBackupHealth": "Estado de las copias de seguridad",
|
||||
"eventBackupHealthDesc": "Cuando una copia de seguridad se detiene, se retrasa o se recupera. Solo se envía a los administradores.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Las copias de seguridad necesitan atención",
|
||||
"titleFailing": "Las copias de seguridad están en riesgo",
|
||||
"titleReminder": "Las copias de seguridad siguen en riesgo",
|
||||
"titleRecovered": "Las copias de seguridad vuelven a la normalidad"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,25 +11162,6 @@
|
||||
"who5": "Test de bienestar, 5 preguntas",
|
||||
"sci": "Test de sueño, 8 preguntas"
|
||||
},
|
||||
"options": {
|
||||
"0": "Nunca",
|
||||
"1": "Varios días",
|
||||
"2": "Más de la mitad de los días",
|
||||
"3": "Casi cada día"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Nunca",
|
||||
"1": "Varios días",
|
||||
"2": "Más de la mitad de los días",
|
||||
"3": "Casi cada día"
|
||||
},
|
||||
"functionalTitle": "Si ha marcado alguno de los problemas de este cuestionario, ¿hasta qué punto estos problemas le han creado dificultades para hacer su trabajo, ocuparse de la casa o relacionarse con los demás?",
|
||||
"functional": {
|
||||
"0": "Ninguna dificultad",
|
||||
"1": "Algunas dificultades",
|
||||
"2": "Muchas dificultades",
|
||||
"3": "Muchísimas dificultades"
|
||||
},
|
||||
"functionalOptionalHint": "Opcional: puedes enviar sin responder esta pregunta.",
|
||||
"result": {
|
||||
"title": "Tu resultado",
|
||||
@@ -11015,35 +11211,6 @@
|
||||
"ifDanger": "Si estás en peligro inmediato, llama ahora al {emergency}.",
|
||||
"resourcesTitle": "Personas que pueden ayudar"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Poco interés o alegría por hacer cosas",
|
||||
"2": "Sensación de estar decaído/a, deprimido/a o desesperanzado/a",
|
||||
"3": "Problemas para quedarse dormido/a, para seguir durmiendo o dormir demasiado",
|
||||
"4": "Sensación de cansancio o de tener poca energía",
|
||||
"5": "Poco apetito o comer demasiado",
|
||||
"6": "Sentirse mal consigo mismo/a; sentir que es un/a fracasado/a o que ha decepcionado a su familia o a sí mismo/a",
|
||||
"7": "Problemas para concentrarse en algo, como leer el periódico o ver la televisión",
|
||||
"8": "Moverse o hablar tan despacio que los demás pueden haberlo notado. O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo de un lado a otro más de lo habitual",
|
||||
"9": "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Sentirse nervioso/a, angustiado/a o muy tenso/a",
|
||||
"2": "Ser incapaz de dejar de preocuparse o de controlar la preocupación",
|
||||
"3": "Preocuparse demasiado por diferentes cuestiones",
|
||||
"4": "Tener problemas para relajarse",
|
||||
"5": "Estar tan inquieto/a que le resulta difícil permanecer sentado/a",
|
||||
"6": "Enfadarse o irritarse con facilidad",
|
||||
"7": "Sentir miedo de que algo terrible pueda ocurrir"
|
||||
},
|
||||
"who5": {
|
||||
"1": "Me he sentido alegre y de buen humor",
|
||||
"2": "Me he sentido tranquilo y relajado",
|
||||
"3": "Me he sentido activo y enérgico",
|
||||
"4": "Me he despertado fresco y descansado",
|
||||
"5": "Mi vida cotidiana ha estado llena de cosas que me interesan"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, gratuito y confidencial)"
|
||||
@@ -11102,26 +11269,6 @@
|
||||
"who5": "Una puntuación de bienestar de 50 o menos puede merecer una mirada más atenta. Si quieres, el PHQ-9 de esta página explora el estado de ánimo con más detalle. Es una sugerencia amable, no un diagnóstico.",
|
||||
"sci": "En el estudio de este cuestionario, puntuaciones de 16 o menos se asociaron con insomnio probable. Si dormir mal te sigue afectando, hablar con un profesional de salud puede ayudar. No es un diagnóstico."
|
||||
},
|
||||
"validatedInEnglishNote": "Este cuestionario está validado en inglés; las preguntas se muestran en su versión original en inglés.",
|
||||
"who5Options": {
|
||||
"0": "Nunca",
|
||||
"1": "De vez en cuando",
|
||||
"2": "Menos de la mitad del tiempo",
|
||||
"3": "Más de la mitad del tiempo",
|
||||
"4": "La mayor parte del tiempo",
|
||||
"5": "Todo el tiempo"
|
||||
},
|
||||
"stems": {
|
||||
"phq9": {
|
||||
"period": "Durante las últimas 2 semanas ¿con qué frecuencia le ha molestado alguno de los siguientes problemas?"
|
||||
},
|
||||
"gad7": {
|
||||
"period": "En los últimos 14 días, ¿con qué frecuencia le han supuesto una molestia los siguientes problemas?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "Durante las últimas dos semanas:"
|
||||
}
|
||||
},
|
||||
"openDetail": "Ver detalles",
|
||||
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.",
|
||||
@@ -11171,6 +11318,13 @@
|
||||
"belowThreshold": "Tu sueño del último mes estuvo en el rango en el que el estudio detrás de este cuestionario consideró probable un insomnio.",
|
||||
"aboveThreshold": "Tu sueño del último mes estuvo fuera del rango que ese estudio asocia con el insomnio."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Esta traducción no ha sido validada científicamente; toma el resultado como orientación.",
|
||||
"showOriginal": "Ver el texto original en inglés",
|
||||
"showTranslation": "Ver la traducción",
|
||||
"resultUnvalidated": "Respondido con una traducción que no ha sido validada científicamente; toma el resultado como orientación.",
|
||||
"historyUnvalidated": "Respondido con una traducción no validada científicamente"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11698,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Ya estaba guardado. Se resalta el documento existente.",
|
||||
"deleted": "Documento eliminado",
|
||||
"restoreFailed": "No se pudo restaurar el documento."
|
||||
"restoreFailed": "No se pudo restaurar el documento.",
|
||||
"deletedHint": "Los documentos eliminados se quedan en Eliminados recientemente durante {days} días."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Cargando más…",
|
||||
@@ -11743,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "El proveedor de IA no devolvió texto para este documento."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "No se pudo cargar este documento.",
|
||||
@@ -11815,7 +11971,7 @@
|
||||
"dismiss": "Descartar",
|
||||
"empty": "No se pudo leer nada de este documento.",
|
||||
"unavailableBody": "Configura un proveedor de IA para sugerir título, tipo y fecha.",
|
||||
"unavailableLocalOcr": "Activa el OCR local en los ajustes de IA para leer imágenes en este dispositivo.",
|
||||
"unavailableLocalOcr": "Activa «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio para leer imágenes en este dispositivo.",
|
||||
"unavailableAction": "Abrir ajustes de IA",
|
||||
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
|
||||
"errorOcr": "No se pudo leer texto de esta imagen.",
|
||||
@@ -11827,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
"errorNotIndexed": "Primero lee el documento y luego extrae.",
|
||||
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
|
||||
"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.",
|
||||
"errorLocalOcrDisabled": "Activa primero «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio.",
|
||||
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
|
||||
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo.",
|
||||
"errorConsent": "Leer documentos con IA necesita primero tu consentimiento.",
|
||||
"errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo."
|
||||
"errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo.",
|
||||
"unavailableLocalOcrAction": "Abrir ajustes de laboratorio"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Resumir",
|
||||
@@ -11887,6 +12045,7 @@
|
||||
"extracting": "Extrayendo…",
|
||||
"extractHint": "Usa el texto ya leído de este documento. Los valores extraídos esperan tu revisión; nada se guarda automáticamente.",
|
||||
"extractEmpty": "No se pudieron extraer valores de este documento.",
|
||||
"extractIncomplete": "Una parte del documento no se pudo leer por completo, así que pueden faltar valores. Compara la lista con el documento.",
|
||||
"failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
|
||||
"failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
|
||||
"failGeneric": "Este valor no se pudo guardar.",
|
||||
@@ -11934,6 +12093,7 @@
|
||||
"loadMore": "Cargar más",
|
||||
"stateImported": "En HealthLog",
|
||||
"stateDeleted": "Eliminado en HealthLog",
|
||||
"stateInTrash": "En Eliminados recientemente",
|
||||
"selectedCount": "{count} de un máximo de {max} seleccionados",
|
||||
"importAction": "Importar {count}",
|
||||
"importing": "Importando, un documento tras otro…",
|
||||
@@ -11947,7 +12107,9 @@
|
||||
"importedLinked": "Importado y vinculado",
|
||||
"duplicate": "Ya está en HealthLog",
|
||||
"duplicateLinked": "Ya está en HealthLog, ahora vinculado",
|
||||
"deleted": "Eliminado en HealthLog, se deja así"
|
||||
"deleted": "Eliminado en HealthLog, se deja así",
|
||||
"restored": "Restaurado desde Eliminados recientemente",
|
||||
"restoredLinked": "Restaurado desde Eliminados recientemente y vinculado"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Los archivos de documentos no están habilitados en este servidor.",
|
||||
@@ -11991,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "¿Eliminar este documento?",
|
||||
"titleMany": "¿Eliminar {count} documentos?",
|
||||
"body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.",
|
||||
"body": "Los documentos eliminados se quedan en Eliminados recientemente durante {days} días; después se eliminan definitivamente.",
|
||||
"action": "Eliminar"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Eliminados recientemente",
|
||||
"subtitle": "Los documentos eliminados se quedan aquí {days} días; después se eliminan definitivamente.",
|
||||
"back": "Documentos",
|
||||
"entryLabel": "Eliminados recientemente ({count})",
|
||||
"loadError": "No se pudo cargar Eliminados recientemente.",
|
||||
"emptyTitle": "No hay nada en Eliminados recientemente",
|
||||
"emptyDescription": "Lo que eliminas espera aquí {days} días antes de eliminarse definitivamente.",
|
||||
"count": "Documentos eliminados ({count})",
|
||||
"restoreAll": "Restaurar todo",
|
||||
"restore": "Restaurar",
|
||||
"purge": "Eliminar ahora",
|
||||
"deletedOn": "Eliminado el {date}.",
|
||||
"removesIn": "Se elimina definitivamente en {days} días.",
|
||||
"removesSoon": "Se elimina definitivamente en menos de un día.",
|
||||
"restored": "Documento restaurado",
|
||||
"openRestored": "Abrir",
|
||||
"restoreDuplicate": "Ya hay un documento idéntico guardado, así que esta copia no se puede restaurar.",
|
||||
"restorePartial": "No se pudieron restaurar {failed} de {total}",
|
||||
"purged": "Documento eliminado definitivamente",
|
||||
"purgeFailed": "No se pudo eliminar el documento.",
|
||||
"purgeConfirmTitle": "¿Eliminar este documento definitivamente?",
|
||||
"purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12677,7 +12863,8 @@
|
||||
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta.",
|
||||
"methodSso": "{name} inicia sesión con el inicio de sesión único cuando usa el enlace.",
|
||||
"methodBoth": "{name} puede elegir una contraseña o iniciar sesión con el inicio de sesión único."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Todo lo registrado para {name} desaparece con el perfil: mediciones, medicamentos, documentos y notas. El perfil se bloquea de inmediato y se borra al cabo de {days} días. Hasta entonces, un administrador de este servidor puede deshacer la eliminación."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}"
|
||||
@@ -12732,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} restauró una entrada de la agenda",
|
||||
"illnessDayLogUpsert": "{name} registró un día de enfermedad",
|
||||
"medicationUpdate": "{name} cambió un medicamento",
|
||||
"medicationCourseEnd": "{name} terminó un tratamiento",
|
||||
"medicationDelete": "{name} eliminó un medicamento",
|
||||
"medicationGlp1Update": "{name} cambió un tratamiento con GLP-1",
|
||||
"medicationScheduleCreate": "{name} añadió una pauta de dosificación",
|
||||
@@ -12779,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado",
|
||||
"rejectedDoseImport": "{name} intentó importar dosis y fue rechazado",
|
||||
"rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado",
|
||||
"rejectedTrackedValueTypeCreate": "{name} intentó añadir un tipo de valor propio y fue rechazado",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} intentó cambiar un tipo de valor propio y fue rechazado",
|
||||
"rejectedMedicationLayout": "{name} intentó reordenar la lista de medicamentos y fue rechazado",
|
||||
"deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola",
|
||||
"anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud",
|
||||
"customMetricCreate": "{name} añadió un tipo de valor propio",
|
||||
@@ -13088,7 +13279,8 @@
|
||||
"backgroundScan": "Leyendo en segundo plano. Con un modelo lento puede tardar unos minutos.",
|
||||
"waitingForWorker": "Esperando al proceso en segundo plano. Si no cambia, puede que el worker del servidor no esté en marcha.",
|
||||
"errorWorkerUnavailable": "El proceso en segundo plano no está disponible ahora mismo. Inténtalo de nuevo en un momento o pide a quien administra el servidor que lo revise.",
|
||||
"errorTimedOut": "La lectura tardó más de lo que permite tu tiempo de respuesta de IA. Inténtalo de nuevo o aumenta el tiempo de respuesta en los ajustes de IA."
|
||||
"errorTimedOut": "La lectura tardó más de lo que permite tu tiempo de respuesta de IA. Inténtalo de nuevo o aumenta el tiempo de respuesta en los ajustes de IA.",
|
||||
"errorReaderBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nuevo síntoma",
|
||||
|
||||
+339
-120
@@ -175,6 +175,9 @@
|
||||
"failed": "L’étiquette n’a pas pu être lue. Essayez une photo plus nette."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites",
|
||||
"window": "analysé du {from} au {to}, ce qui peut dépasser la période du rapport",
|
||||
"windowLine": "Analysé du {from} au {to}. Cette fenêtre peut dépasser la période du rapport."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label} : {count} résultat(s) sur {total} ont été obtenus avec une traduction non validée scientifiquement ; à considérer comme une indication."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Invitation invalide",
|
||||
"description": "Ce lien d’invitation est incomplet ou a été mal copié. Demandez un nouveau lien à la personne qui vous a invité.",
|
||||
"toLogin": "Aller à la connexion"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Ce compte sera supprimé le {date} et reste verrouillé d’ici là. Un administrateur de ce serveur peut annuler la suppression avant cette date.",
|
||||
"deletionPendingNoDate": "Ce compte est programmé pour suppression et il est verrouillé. Un administrateur de ce serveur peut annuler la suppression tant qu’elle n’est pas terminée."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Sauvegarde ta clé de chiffrement",
|
||||
"body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.",
|
||||
"action": "Ouvrir l'étape"
|
||||
},
|
||||
"quickAddSymptom": "Noter un symptôme",
|
||||
"todayCardVisible": "Afficher la carte du haut"
|
||||
"todayCardVisible": "Afficher la carte du haut",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Sauvegardez votre kit de récupération",
|
||||
"body": "Sans copies des clés utilisées par ce serveur, ses données ne peuvent pas être récupérées après une réinstallation. Ça prend quelques minutes.",
|
||||
"action": "Ouvrir le kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mesures",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.",
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.",
|
||||
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
|
||||
"errorReasoningOnly": "Le modèle a seulement réfléchi, sans écrire de réponse. Réessaie ou choisis un autre modèle dans les réglages IA.",
|
||||
"errorDemo": "Dans la démo, le Coach ne répond pas.",
|
||||
"errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.",
|
||||
"errorNoProviderAction": "Connecter un fournisseur d'IA",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
|
||||
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
|
||||
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
|
||||
"detail": "Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
|
||||
"detail": "Une copie portable de votre dossier, à conserver et à lire : valeurs en JSON simple, documents limités à leurs métadonnées. Ce n’est pas la copie hebdomadaire du compte que ce serveur garde pour restaurer un compte, et elle ne peut pas recréer un compte sur un autre serveur. Certains types de données ne sont pas encore inclus ; le manifeste du fichier indique ce qu’il contient.",
|
||||
"contents": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Fichier JSON",
|
||||
"title": "Mesures et humeur (JSON)",
|
||||
"description": "Collez du JSON ou importez un fichier .json contenant des mesures et des entrées d'humeur.",
|
||||
"pasteLabel": "Coller du JSON",
|
||||
"fileInputLabel": "Choisir un fichier JSON à importer",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Impossible de lire ce fichier.",
|
||||
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
|
||||
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées",
|
||||
"droppedLinks": "Importé sans ces étiquettes, car ce compte n’en a aucune de ce nom : {keys}",
|
||||
"keptLabels": "Ce compte a déjà des étiquettes ou des groupes avec ces clés sous son propre nom, ils ont été conservés : {keys}",
|
||||
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
|
||||
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
|
||||
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur.",
|
||||
"limitsHint": "Jusqu’à {mb} Mo et jusqu’à {rows} entrées par liste. Cela ajoute des entrées à votre dossier ; cela ne restaure pas une sauvegarde complète."
|
||||
},
|
||||
"csv": {
|
||||
"title": "Fichier CSV",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
|
||||
"subtitle": "Exportez vos données, ou importez des mesures et des entrées depuis d’autres applis et fichiers."
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos de cette app",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "Le jeton n'est affiché qu'une fois.",
|
||||
"scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.",
|
||||
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.",
|
||||
"namePlaceholder": "Nom, p. ex. Home Assistant"
|
||||
"namePlaceholder": "Nom, p. ex. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Écrire les mesures dans Apple Health",
|
||||
"createHint": "L'app HealthLog de votre iPhone ajoutera alors les mesures de ce jeton à Apple Health. Vous pourrez modifier ce choix plus tard dans la liste des jetons ci-dessous.",
|
||||
"toggleAria": "Écrire les mesures de {name} dans Apple Health",
|
||||
"updateFailed": "Le réglage Apple Health n'a pas pu être modifié. Veuillez réessayer.",
|
||||
"unattributed": "Les mesures envoyées avant l'existence de ce réglage ne peuvent être rattachées à aucun jeton : elles restent donc hors d'Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Jetons d'entraînements",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Supprime votre compte ainsi que les clés d’accès, le journal d’audit et les sessions.",
|
||||
"deleteAccountCta": "Supprimer le compte",
|
||||
"deleteAccountConfirmTitle": "Supprimer définitivement le compte ?",
|
||||
"deleteAccountConfirmDescription": "Votre compte, toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit seront définitivement supprimés. Vous serez déconnecté immédiatement après.",
|
||||
"deleteAccountConfirmTitleGrace": "Supprimer votre compte ?",
|
||||
"deleteAccountConfirmDescription": "Votre compte, toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit seront définitivement supprimés. Vous serez déconnecté immédiatement après. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
|
||||
"deleteAccountSuccess": "Compte supprimé, déconnexion en cours.",
|
||||
"deleteAccountFailed": "Le compte n’a pas pu être supprimé",
|
||||
"deleteAccountFinal": "Supprimer définitivement",
|
||||
"deleteAccountFinalGrace": "Supprimer le compte",
|
||||
"saved": "Enregistré",
|
||||
"savingError": "Erreur lors de l’enregistrement",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Conversations avec le coach : {days} jours",
|
||||
"audit": "Journal de sécurité : {days} jours",
|
||||
"delivery": "Journal d'envoi des notifications : {days} jours",
|
||||
"unavailable": "Les détails de conservation sont indisponibles pour le moment."
|
||||
"unavailable": "Les détails de conservation sont indisponibles pour le moment.",
|
||||
"accountDeletion": "Suppression de votre compte, d’un profil géré ou de toutes les données : effective au bout de {days} jours",
|
||||
"accountDeletionImmediate": "Suppression de votre compte, d’un profil géré ou de toutes les données : effective immédiatement"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer ou réinitialiser",
|
||||
"description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.",
|
||||
"backupLag": "La suppression efface vos données immédiatement. Si ce serveur conserve des sauvegardes externes chiffrées, les copies de vos données sont supprimées de ce stockage juste après, ou lors du prochain passage nocturne s'il est injoignable.",
|
||||
"openAdvanced": "Supprimer ou réinitialiser"
|
||||
"openAdvanced": "Supprimer ou réinitialiser",
|
||||
"immediateNotice": "La suppression efface vos données immédiatement. Si ce serveur conserve des sauvegardes externes chiffrées, les copies de vos données sont supprimées de ce stockage juste après, ou lors du prochain passage nocturne s'il est injoignable. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
|
||||
"graceNotice": "Lorsque vous supprimez votre compte, il est verrouillé immédiatement et effacé au bout de {days} jours, avec chaque copie que ce serveur en conserve, y compris les copies chiffrées hors du serveur. D’ici là, un administrateur de ce serveur peut annuler la suppression. La suppression de toutes les données fonctionne de la même façon : une copie du dossier est conservée pendant {days} jours, afin qu’un administrateur puisse la restaurer. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Posture de confidentialité",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Désactivé",
|
||||
"change": "Modifier dans Modules"
|
||||
},
|
||||
"timezoneUseDevice": "Utiliser le fuseau horaire de cet appareil ({zone})"
|
||||
"timezoneUseDevice": "Utiliser le fuseau horaire de cet appareil ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Cette action supprime tout ce que contient votre dossier : mesures, médicaments, entrées d’humeur, résultats de laboratoire, entrées de cycle, documents, conversations avec l’IA et toutes les connexions d’intégration, ainsi que tout ce qui en découle et tout ce que vous aviez supprimé auparavant. Les liens de partage remis à un cabinet cessent aussitôt de fonctionner. Votre compte reste en place, de même que vos identifiants de connexion et vos réglages d’affichage comme la langue, le fuseau horaire et les unités. Une copie de votre dossier est conservée pendant {days} jours, afin qu’un administrateur de ce serveur puisse la restaurer d’ici là. Ensuite, la suppression est définitive.",
|
||||
"deleteAccountCardDetailGrace": "Votre compte est verrouillé immédiatement et effacé au bout de {days} jours. D’ici là, un administrateur de ce serveur peut annuler la suppression.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Impossible de forcer votre propre déconnexion",
|
||||
"emptyTitle": "Aucun utilisateur dans cette vue",
|
||||
"emptyDescription": "Aucun utilisateur ne correspond au filtre actuel.",
|
||||
"emptyResetFilter": "Afficher tous les utilisateurs"
|
||||
"emptyResetFilter": "Afficher tous les utilisateurs",
|
||||
"deletionPending": "Suppression le {date}",
|
||||
"deletionPendingNoDate": "Suppression programmée",
|
||||
"undoDeletion": "Annuler la suppression",
|
||||
"undoDeletionTitle": "Annuler la suppression de {name} ?",
|
||||
"undoDeletionBody": "{name} a demandé la suppression de ce compte, qui sera effacé à la fin du délai de grâce. Annuler conserve le compte et tout son contenu tels quels. {name} peut se reconnecter, mais les jetons d’API, les liens de partage et les appareils de confiance restent révoqués et doivent être configurés à nouveau.",
|
||||
"undoDeletionBodyGuardian": "Une personne responsable a supprimé le profil géré {name}, qui sera effacé à la fin du délai de grâce. Annuler conserve le profil et tout son contenu tels quels, et les personnes responsables y ont de nouveau accès. Ses liens de partage et jetons restent révoqués et doivent être configurés à nouveau.",
|
||||
"undoDeletionDone": "Suppression de {name} annulée",
|
||||
"undoDeletionFailed": "Impossible d’annuler la suppression"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Invitations",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Sauvegardes",
|
||||
"subtitle": "Instantanés de données hebdomadaires par utilisateur stockés dans PostgreSQL.",
|
||||
"description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré. Télécharger déchiffre l'instantané pour l'administrateur qui le demande ; le texte chiffré reste dans la base de données.",
|
||||
"subtitle": "Copies de comptes dans la base de données de ce serveur, pour restaurer un compte ici.",
|
||||
"description": "Chaque compte dispose d’une copie hebdomadaire, remplacée chaque dimanche, ainsi que des copies téléversées ici et de la copie prise avant « Supprimer toutes les données ». Une copie restaure un compte existant sur ce serveur. Elle ne peut pas reconstruire un serveur perdu : il faut pour cela une sauvegarde de la base de données et les clés de chiffrement. Les tailles indiquent la copie chiffrée. Le téléchargement la déchiffre pour l’administrateur qui la demande ; le texte chiffré reste dans la base de données.",
|
||||
"runNow": "Sauvegarder maintenant",
|
||||
"runEnqueued": "Tâche de sauvegarde mise en file d'attente",
|
||||
"runFailed": "Échec de la mise en file d'attente de la sauvegarde",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Les sauvegardes s'exécutent automatiquement chaque dimanche à 03:00, ou lancez-en une maintenant pour créer le premier instantané.",
|
||||
"colUser": "Utilisateur",
|
||||
"colType": "Type",
|
||||
"typeWeeklyAuto": "Hebdomadaire (auto)",
|
||||
"typeWeeklyAuto": "Copie hebdomadaire du compte",
|
||||
"typeManual": "Manuelle",
|
||||
"colSize": "Taille",
|
||||
"colCreatedAt": "Dernier instantané",
|
||||
"colActions": "Actions",
|
||||
"retentionLabel": "Conservation : {days} jours",
|
||||
"scheduleStaleTitle": "La sauvegarde hebdomadaire s'est arrêtée",
|
||||
"scheduleStaleDescription": "La copie planifiée la plus récente date de {days} jours. Une exécution hebdomadaire qui fonctionne n'en laisse jamais une dépasser {threshold} jours.",
|
||||
"scheduleFailedTitle": "La dernière exécution planifiée de la sauvegarde a échoué",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"uploadTitle": "Téléverser une sauvegarde",
|
||||
"uploadDescription": "Validez un fichier de sauvegarde et stockez-le comme instantané manuel. La restauration est une étape distincte.",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
|
||||
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Réglage du compte",
|
||||
"restoreMissingTitle": "Restauration refusée : le fichier est incomplet",
|
||||
"restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.",
|
||||
"restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.",
|
||||
"restoreMissingRemedy": "Utilisez l’une des copies de compte de ce serveur ou une copie hors du serveur, ou exportez à nouveau le compte et téléversez le nouveau fichier. Une section que le manifeste déclare omise est un autre cas et se restaure normalement.",
|
||||
"restoreMissingDocuments": "Documents",
|
||||
"restoreMissingWorkouts": "Séances",
|
||||
"restoreMissingMentalHealth": "Questionnaires de santé mentale",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Une restauration de ce compte est déjà en cours. Attends qu'elle se termine.",
|
||||
"restoreJobQueuedTitle": "La restauration de {username} attend de démarrer",
|
||||
"restoreJobQueuedNote": "Elle démarre dès que le processus en arrière-plan la prend en charge.",
|
||||
"restoreJobWaitingBackupNote": "En attente de la sauvegarde en cours sur le serveur. Une restauration ne tourne jamais en même temps qu’une sauvegarde, et elle démarre environ une minute après la fin de celle-ci.",
|
||||
"restoreJobWaitingBusyNote": "En attente de la fin d’une tâche lourde sur le serveur, comme une importation ou un traitement nocturne. Elle démarre environ une minute après.",
|
||||
"restoreJobQueuedSince": "En file d’attente depuis {when}.",
|
||||
"restoreJobRunningTitle": "Restauration de {username} en cours",
|
||||
"restoreJobRunningNote": "Le compte affiche ses données actuelles jusqu'à la fin de la restauration. Tu peux quitter cette page ; la restauration continue sur le serveur.",
|
||||
"restoreJobResumed": "Relancée après le redémarrage du serveur.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "Le fichier de la sauvegarde appartient à un autre compte que la ligne de sauvegarde.",
|
||||
"restoreFailureOwnerMissing": "Le compte auquel appartient cette sauvegarde n'existe plus.",
|
||||
"restoreFailureSectionMissing": "Le fichier est incomplet : son manifeste nomme une section qu'il ne contient pas.",
|
||||
"restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilise une sauvegarde de reprise après sinistre.",
|
||||
"restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilisez plutôt l’une des copies de compte de ce serveur ou une copie hors du serveur.",
|
||||
"restoreFailureTimeBudget": "Cette sauvegarde est trop volumineuse pour être restaurée dans le temps imparti au processus en arrière-plan.",
|
||||
"restoreFailureTransaction": "La base de données a refusé la restauration en cours de route, tout a donc été annulé. Le journal du serveur indique l'erreur.",
|
||||
"restoreFailureCauseTimeout": "La restauration a duré plus longtemps que la base de données ne l'autorise, tout a donc été annulé. Si l'hôte de la base était chargé, relancez-la quand il est plus calme. Le journal du serveur indique l'étape.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)",
|
||||
"restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)",
|
||||
"restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)",
|
||||
"typePreWipe": "Avant la suppression de toutes les données",
|
||||
"retentionNote": "La copie hebdomadaire est remplacée chaque dimanche. Une copie prise avant « Supprimer toutes les données » est supprimée au bout de {days} jours. Les copies téléversées restent jusqu’à l’effacement de leur compte.",
|
||||
"retentionNoteNoGrace": "La copie hebdomadaire est remplacée chaque dimanche. Les copies téléversées restent jusqu’à l’effacement de leur compte.",
|
||||
"database": {
|
||||
"title": "Dump de la base de données",
|
||||
"lastGood": "Dernier dump valide {when}, {size}, {where}.",
|
||||
"whereBoth": "conservé sur ce serveur et hors site",
|
||||
"whereLocal": "conservé sur ce serveur",
|
||||
"whereOffhost": "hors site uniquement",
|
||||
"none": "Aucun dump de la base de données pour l'instant. Le premier a lieu cette nuit.",
|
||||
"failedTitle": "Le dernier dump de la base de données a échoué",
|
||||
"failedDescription": "{when} : {reason}",
|
||||
"disabled": "Les dumps de la base de données sont désactivés (DB_BACKUP_ENABLED=false). Sauvegardez la base de données autrement.",
|
||||
"noDirectoryOffhost": "Ce serveur ne peut pas écrire dans {dir} : les dumps partent uniquement hors site et aucun n'est conservé ici. Montez le volume de sauvegarde.",
|
||||
"noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde.",
|
||||
"retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} dumps nocturnes les plus récents, et le dump pris avant la dernière mise à jour reste jusqu'à la suivante.",
|
||||
"retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.",
|
||||
"retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.",
|
||||
"notPersistentOffhost": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) : un dump écrit là serait perdu à la prochaine mise à jour. Les dumps partent uniquement hors site : aucun n'est conservé ici et aucun n'est pris avant une migration. Montez le volume de sauvegarde.",
|
||||
"notPersistent": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) et aucun bucket hors site n'est configuré : aucun dump de la base n'est conservé nulle part. Montez le volume de sauvegarde.",
|
||||
"offhostFailedTitle": "Le dump le plus récent de la base de données n'est pas arrivé dans le bucket externe",
|
||||
"offhostLastUpload": "Le dump le plus récent du bucket date du {when}.",
|
||||
"offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Masquer le tableau",
|
||||
"columnsShowAll": "Afficher les {count} colonnes",
|
||||
"columnsShowWithRows": "Seulement les colonnes avec des lignes",
|
||||
"columnsNoneWithRows": "Aucune colonne chiffrée ne contient encore de lignes."
|
||||
"columnsNoneWithRows": "Aucune colonne chiffrée ne contient encore de lignes.",
|
||||
"backupsDumpLocalLine": "Clé {key} : {count} dumps de la base de données sur ce serveur ont été écrits avec elle ; elle est nécessaire jusqu'à ce que la rétention les supprime.",
|
||||
"backupsDumpOffhostLine": "Clé {key} : le dump de la base de données le plus récent écrit avec elle est arrivé dans le bucket le {when} ; nécessaire jusqu'au {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Clé {key} : le dump de la base de données le plus récent écrit avec elle est arrivé dans le bucket le {when}. La durée de conservation de database/ dans le bucket est inconnue : gardez la clé jusqu'à être sûr que ce dump a disparu."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance de la table des mesures",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Cette partie de la page n’a pas pu s’afficher",
|
||||
"description": "Le reste de la page fonctionne ; réessayez ou rechargez si le problème persiste."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Sauvegarde ta clé de chiffrement",
|
||||
"description": "Sans copie de cette clé, tes données chiffrées et les sauvegardes de ce serveur ne pourront plus être lues.",
|
||||
"loading": "Chargement de l'état de la sauvegarde de la clé…",
|
||||
"loadError": "Impossible de charger l'état de la sauvegarde de la clé.",
|
||||
"badgeDue": "À faire",
|
||||
"badgeDone": "Sauvegardée",
|
||||
"keyId": "ID de la clé active",
|
||||
"fingerprint": "Empreinte",
|
||||
"whatIsLost": "Garde une copie de la clé en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Si le serveur est réinstallé avec une autre clé, HealthLog ne sert plus rien tant que la clé d'origine n'est pas revenue.",
|
||||
"fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 de la clé. Elle identifie la clé sans la révéler, pour que tu puisses la comparer à ta copie.",
|
||||
"checkLabel": "Vérifier ma copie",
|
||||
"checkButton": "Vérifier",
|
||||
"checkNote": "La clé collée est comparée à la clé active puis oubliée. Elle n'est jamais stockée ni journalisée.",
|
||||
"checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme ton mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.",
|
||||
"checkMatches": "Ta copie correspond à la clé active.",
|
||||
"checkNoMatch": "Ta copie ne correspond pas à la clé active. Vérifie que tu as copié toute la valeur.",
|
||||
"checkFailed": "La vérification a échoué.",
|
||||
"confirmButton": "J'ai sauvegardé cette clé",
|
||||
"confirmed": "Sauvegarde de la clé confirmée.",
|
||||
"confirmFailed": "Impossible d'enregistrer la confirmation.",
|
||||
"stale": "La clé du serveur a changé depuis le chargement de cette page. Vérifie le nouvel ID et l'empreinte, puis confirme à nouveau.",
|
||||
"confirmedAt": "Confirmée le {date}.",
|
||||
"confirmedAtBy": "Confirmée le {date} par {who}.",
|
||||
"dueAgain": "Une sauvegarde a été confirmée pour la clé {keyId}, mais le serveur utilise maintenant une autre clé. Sauvegarde la clé actuelle.",
|
||||
"tileLabel": "Sauvegarde de la clé de chiffrement",
|
||||
"tileDue": "Non confirmée",
|
||||
"tileDone": "Confirmée",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Ouvre Apps, sélectionne HealthLog, choisis Modifier et copie ENCRYPTION_KEY dans les variables d'environnement.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Dans l'onglet Docker, modifie le conteneur HealthLog et copie ENCRYPTION_KEY depuis le modèle, ou depuis le .env de ta pile Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Ouvre la pile HealthLog dans Portainer et copie ENCRYPTION_KEY depuis ses variables d'environnement."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog ne peut pas ouvrir ses données",
|
||||
"body": "La clé de chiffrement avec laquelle ce serveur a démarré n'est pas celle avec laquelle sa base de données a été écrite. Pour protéger tes données, HealthLog ne sert rien tant que ce n'est pas corrigé.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.",
|
||||
"gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)",
|
||||
"gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de récupération",
|
||||
"description": "Chaque clé nécessaire à la restauration de ce serveur, avec son ID et son empreinte.",
|
||||
"intro": "Gardez une copie de chaque clé ci-dessous en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Sans elles, ni un dump de la base de données ni une copie de sauvegarde ne peuvent être relus, et HealthLog refuse de démarrer s'il manque une clé dont il a besoin.",
|
||||
"loading": "Chargement du kit de récupération…",
|
||||
"loadError": "Impossible de charger le kit de récupération.",
|
||||
"badgeDue": "À faire",
|
||||
"badgeDone": "Sauvegardé",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Empreinte",
|
||||
"noFingerprint": "Absente de ce serveur",
|
||||
"kind": {
|
||||
"application": "Clé d'application {id}",
|
||||
"backup": "Clé des sauvegardes hors hôte",
|
||||
"backupPrevious": "Ancienne clé des sauvegardes hors hôte",
|
||||
"token": "Clé des jetons"
|
||||
},
|
||||
"need": {
|
||||
"active": "Chiffre les nouvelles données.",
|
||||
"configured": "Présente sur ce serveur.",
|
||||
"storedCopies": "Nécessaire à {count} copies stockées.",
|
||||
"offhostUntil": "Nécessaire aux copies hors hôte jusqu'au {date}.",
|
||||
"offhostUnknown": "Nécessaire aux copies hors hôte tant que le bucket les conserve.",
|
||||
"backupActive": "Scelle chaque nouvelle copie hors hôte.",
|
||||
"backupPrevious": "Ouvre les copies hors hôte écrites avant le dernier changement de clé.",
|
||||
"token": "Signe les sessions, les jetons et les liens de partage. Sans elle, tout le monde est déconnecté, mais aucune donnée n'est perdue.",
|
||||
"localDumps": "Nécessaire à {count} dumps de la base de données sur ce serveur.",
|
||||
"offhostDumpsUntil": "Nécessaire aux dumps de la base de données hors hôte jusqu'au {date}.",
|
||||
"offhostDumpsUnknown": "Nécessaire aux dumps de la base de données hors hôte tant que le bucket les conserve."
|
||||
},
|
||||
"source": "Définie dans {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Sauvegardée",
|
||||
"new": "Pas encore sauvegardée",
|
||||
"notConfigured": "Absente de ce serveur",
|
||||
"unreadable": "Clé non valide"
|
||||
},
|
||||
"notConfiguredHint": "Une sauvegarde a encore besoin de cette clé et ce serveur ne l'a pas. Seule une copie que vous conservez ailleurs peut lire ces sauvegardes. Confirmez le kit une fois que vous avez cette copie.",
|
||||
"unreadableHint": "La variable est définie mais ne contient pas de clé valide. Corrigez-la avant de vous fier aux copies hors hôte.",
|
||||
"fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 d'une clé. Elle identifie la clé sans la révéler, pour que vous puissiez la comparer à votre copie.",
|
||||
"whereTitle": "Où les clés sont définies",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Les clés se trouvent dans le fichier .env à côté de docker-compose.yml : ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY et, avec les copies hors hôte, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Ouvrez Apps, sélectionnez HealthLog et choisissez Edit. Les clés se trouvent sous HealthLog Configuration ; pour une app installée via YAML, ce sont ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY dans les paramètres d'environnement.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Ouvrez l'application dans Coolify et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Dans l'onglet Docker, modifiez le conteneur HealthLog et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis le modèle, ou depuis le .env de votre pile Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Ouvrez la pile HealthLog dans Portainer et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses variables d'environnement."
|
||||
},
|
||||
"restoreTitle": "Restaurer ce serveur",
|
||||
"restoreBody": "Installez HealthLog avec les mêmes clés, restaurez le dump de la base de données le plus récent dans une base vide, puis démarrez l'app. Le guide détaille chaque étape.",
|
||||
"restoreLink": "Guide de reprise après sinistre",
|
||||
"checkLabel": "Vérifier ma copie",
|
||||
"checkKeyLabel": "Clé à vérifier",
|
||||
"checkValueLabel": "Votre copie de la clé",
|
||||
"checkButton": "Vérifier",
|
||||
"checkNote": "La clé collée est comparée à celle que détient ce serveur puis oubliée. Elle n'est jamais stockée ni journalisée.",
|
||||
"checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme votre mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.",
|
||||
"checkMatches": "Votre copie correspond à cette clé.",
|
||||
"checkNoMatch": "Votre copie ne correspond pas à cette clé. Vérifiez que vous avez copié toute la valeur.",
|
||||
"checkFailed": "La vérification a échoué.",
|
||||
"confirmButton": "J'ai une copie de chaque clé",
|
||||
"confirmed": "Kit de récupération confirmé.",
|
||||
"confirmFailed": "Impossible d'enregistrer la confirmation.",
|
||||
"stale": "Le kit a changé depuis le chargement de cette page. Vérifiez les clés qu'il liste maintenant, puis confirmez à nouveau.",
|
||||
"confirmedAt": "Confirmé le {date}.",
|
||||
"confirmedAtBy": "Confirmé le {date} par {who}.",
|
||||
"dueAgain": "Le kit a été confirmé le {date}. Il liste désormais aussi {keys}, que cette confirmation ne couvrait pas. Sauvegardez une copie de chacune et confirmez à nouveau."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Capacité de restauration",
|
||||
"description": "Indique si ce serveur pourrait être remis en route après une perte, d'après chaque sauvegarde qu'il réalise.",
|
||||
"loading": "Vérification des sauvegardes…",
|
||||
"loadError": "Impossible de charger la capacité de restauration.",
|
||||
"state": {
|
||||
"ok": "Prêt",
|
||||
"attention": "Demande de l'attention",
|
||||
"failing": "Menacé"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump de la base de données",
|
||||
"weekly_copy": "Copies hebdomadaires des comptes",
|
||||
"offhost_copies": "Copies hors hôte",
|
||||
"restore_drill": "Test de restauration",
|
||||
"offhost_setup": "Sauvegarde hors hôte",
|
||||
"recovery_kit": "Kit de récupération",
|
||||
"dump_retention": "Dumps dans le bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump le plus récent : {date}",
|
||||
"weekly_copy": "Copie de compte la plus ancienne : {date}",
|
||||
"offhost_copies": "Copie hors hôte la plus ancienne : {date}",
|
||||
"restore_drill": "Dernier test : {date}",
|
||||
"recovery_kit": "Confirmé : {date}"
|
||||
},
|
||||
"dueBy": "À faire avant le {date}",
|
||||
"fix": {
|
||||
"database_dump": "Ouvrir les sauvegardes",
|
||||
"weekly_copy": "Ouvrir les sauvegardes",
|
||||
"offhost_copies": "Ouvrir les sauvegardes",
|
||||
"restore_drill": "Ouvrir les sauvegardes",
|
||||
"offhost_setup": "Ouvrir les sauvegardes",
|
||||
"recovery_kit": "Ouvrir le kit",
|
||||
"dump_retention": "Ouvrir les sauvegardes"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Cette version ne planifie pas de dump de la base de données.",
|
||||
"disabled": "Le dump planifié de la base de données est désactivé, donc aucune copie ne peut reconstruire ce serveur. Pour le réactiver, supprimez DB_BACKUP_ENABLED ou mettez-le à true, puis redémarrez HealthLog.",
|
||||
"unreadable": "L'état du dump de la base de données n'a pas pu être lu.",
|
||||
"never_succeeded": "Aucun dump de la base de données n'a encore réussi, et la dernière tentative a échoué.",
|
||||
"never_ran": "Aucun dump de la base de données n'a été écrit dans les {hours} heures écoulées depuis l'activation des dumps.",
|
||||
"first_pending": "Le premier dump de la base de données n'a pas encore été lancé.",
|
||||
"stale": "Le dump de la base de données le plus récent date de {hours} heures.",
|
||||
"last_failed": "Le dernier dump de la base de données a échoué. Le précédent est encore récent.",
|
||||
"fresh": "Le dernier dump de la base de données est récent.",
|
||||
"nowhere_to_write": "Le dossier des dumps {directory} n'est pas accessible en écriture et aucun bucket externe n'est configuré, donc aucun dump ne peut être conservé.",
|
||||
"not_persistent": "Le répertoire des dumps {directory} n'est pas sur un stockage persistant et aucun bucket hors site n'est configuré : chaque dump serait perdu à la prochaine mise à jour. Montez le volume de sauvegarde.",
|
||||
"fresh_offhost_only": "Le dump de base le plus récent est à jour et se trouve dans le bucket hors site. Aucun n'est conservé sur ce serveur, car {directory} n'est pas sur un stockage persistant, et aucun n'est pris avant une migration.",
|
||||
"offhost_failed": "Le dump le plus récent de la base de données est conservé sur ce serveur, mais son envoi vers le bucket externe a échoué.",
|
||||
"offhost_stale": "Aucun dump de la base de données n'est arrivé dans le bucket externe depuis {hours} heures : les envois échouent à chaque fois. Si ce serveur est perdu, aucun dump récent ne permet de le reconstruire.",
|
||||
"offhost_never": "Aucun dump de la base de données n'est jamais arrivé dans le bucket externe : l'envoi échoue. Si ce serveur est perdu, aucun dump ne permet de le reconstruire."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Il n'y a pas encore de compte à copier.",
|
||||
"stale": "La copie hebdomadaire la plus ancienne date de {days} jours. Comptes en retard : {accounts}.",
|
||||
"never": "Un compte n'a pas eu de copie hebdomadaire depuis {days} jours. Comptes en retard : {accounts}.",
|
||||
"first_pending": "La première copie hebdomadaire est faite tôt le dimanche matin.",
|
||||
"fresh": "Chaque compte a une copie de la semaine dernière."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Aucun bucket hors hôte n'est configuré.",
|
||||
"stale": "Les copies hors hôte ont plus de deux nuits de retard. Comptes en retard : {accounts}.",
|
||||
"first_pending": "L'envoi nocturne hors hôte n'a encore atteint aucun compte.",
|
||||
"fresh": "Chaque compte a une copie récente dans le bucket hors hôte."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Le test lit le bucket hors hôte, qui n'est pas configuré.",
|
||||
"not_run_yet": "Le test de restauration mensuel n'a pas encore été lancé.",
|
||||
"failed": "Le dernier test de restauration a échoué : {failed} copies sur {checked} n'ont pas pu être relues.",
|
||||
"failed_run": "Le dernier test de restauration n'a pas pu aller jusqu'au bout.",
|
||||
"passed": "Le dernier test de restauration a relu {checked} copies.",
|
||||
"nothing_checked": "Le dernier test de restauration s'est arrêté sur sa limite de temps avant de relire une seule copie : il n'a rien prouvé."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Les copies quittent ce serveur chaque nuit.",
|
||||
"no_admin": "Il n'y a pas encore de compte administrateur.",
|
||||
"not_configured": "Aucun bucket hors hôte n'est configuré, donc chaque copie se trouve uniquement sur ce serveur.",
|
||||
"not_configured_grace": "Aucun bucket hors hôte n'est encore configuré, donc chaque copie se trouve uniquement sur ce serveur."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Une copie de chaque clé nécessaire à une restauration est conservée.",
|
||||
"out_of_date": "Le kit de récupération liste désormais aussi {keys}, que la dernière confirmation ne couvrait pas. Rien n'est cassé : sauvegardez une copie et confirmez à nouveau le kit.",
|
||||
"no_admin": "Il n'y a pas encore de compte administrateur.",
|
||||
"not_confirmed": "Personne n'a confirmé de copie des clés nécessaires à une restauration : {keys}.",
|
||||
"not_confirmed_grace": "Sauvegardez une copie des clés nécessaires à une restauration : {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Aucun bucket hors site n'est configuré.",
|
||||
"no_offhost_dumps": "Aucun dump de la base ne part dans le bucket.",
|
||||
"missing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.",
|
||||
"configured": "Le bucket efface les dumps au bout de {days} jours.",
|
||||
"unknown": "Le bucket n'indique pas si une règle efface les anciens dumps. Vérifiez qu'une règle de cycle de vie couvre database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Le dump de la base de données est de nouveau récent.",
|
||||
"weekly_copy": "Chaque compte a de nouveau une copie hebdomadaire récente.",
|
||||
"offhost_copies": "Chaque compte a de nouveau une copie hors hôte récente.",
|
||||
"restore_drill": "Le test de restauration réussit de nouveau.",
|
||||
"offhost_setup": "Les copies hors hôte sont maintenant configurées.",
|
||||
"recovery_kit": "Le kit de récupération est de nouveau confirmé.",
|
||||
"dump_retention": "Une règle de cycle de vie du bucket couvre désormais les dumps de la base."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées",
|
||||
"offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via la requête en ligne vers {host}, donc chaque adresse IP de connexion quitte ce serveur. Pour les résoudre localement, téléchargez les bases GeoLite2-City et GeoLite2-ASN avec un compte MaxMind gratuit, montez le répertoire dans le conteneur et faites-y pointer GEOLITE2_DIR. Étapes : {docsUrl}",
|
||||
"tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}",
|
||||
"tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Le compte n’a pas pu être effacé : {username}",
|
||||
"accountErasureRefusedBody": "Le délai de suppression de {username} est écoulé, mais ce compte est la dernière personne responsable active d’un profil géré : il n’a donc pas été effacé. Il reste verrouillé. Ajoutez une autre personne responsable au profil, supprimez le profil ou annulez la suppression dans Administration, Utilisateurs.",
|
||||
"accountErasureFailedBody": "Le délai de suppression de {username} est écoulé, mais l’effacement du compte a échoué. Il reste verrouillé et une nouvelle tentative a lieu chaque heure, après les autres comptes en attente. L’erreur figure dans la carte des tâches en échec, dans Administration."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Rappels de bilans",
|
||||
"description": "Activez ou désactivez chaque rappel de bilan."
|
||||
},
|
||||
"eventBackupHealth": "État des sauvegardes",
|
||||
"eventBackupHealthDesc": "Quand une sauvegarde s'arrête, prend du retard ou repart. Envoyé uniquement aux administrateurs.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Les sauvegardes demandent de l'attention",
|
||||
"titleFailing": "Les sauvegardes sont menacées",
|
||||
"titleReminder": "Les sauvegardes sont toujours menacées",
|
||||
"titleRecovered": "Les sauvegardes sont revenues à la normale"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,12 +11162,6 @@
|
||||
"who5": "Test de bien-être, 5 questions",
|
||||
"sci": "Test de sommeil, 8 questions"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Jamais",
|
||||
"1": "Plusieurs jours",
|
||||
"2": "Plus de la moitié du temps",
|
||||
"3": "Presque tous les jours"
|
||||
},
|
||||
"functionalOptionalHint": "Facultatif : tu peux envoyer sans répondre à cette question.",
|
||||
"result": {
|
||||
"title": "Ton résultat",
|
||||
@@ -11002,24 +11211,6 @@
|
||||
"ifDanger": "Si tu es en danger immédiat, appelle le {emergency} maintenant.",
|
||||
"resourcesTitle": "Des personnes qui peuvent aider"
|
||||
},
|
||||
"items": {
|
||||
"gad7": {
|
||||
"1": "Un sentiment de nervosité, d’anxiété ou de tension",
|
||||
"2": "Une incapacité à arrêter de s’inquiéter ou à contrôler ses inquiétudes",
|
||||
"3": "Une inquiétude excessive à propos de différentes choses",
|
||||
"4": "Des difficultés à se détendre",
|
||||
"5": "Une agitation telle qu’il est difficile à tenir en place",
|
||||
"6": "Une tendance à être facilement contrarié(e) ou irritable",
|
||||
"7": "Un sentiment de peur comme si quelque chose de terrible risquait de se produire"
|
||||
},
|
||||
"who5": {
|
||||
"1": "Je me suis senti(e) bien et de bonne humeur",
|
||||
"2": "Je me suis senti(e) calme et tranquille",
|
||||
"3": "Je me suis senti(e) plein(e) d'énergie et vigoureux(se)",
|
||||
"4": "Je me suis réveillé(e) en me sentant frais(che) et dispos(e)",
|
||||
"5": "Ma vie quotidienne a été remplie de choses intéressantes"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24h/24, 7j/7, gratuit et confidentiel)"
|
||||
@@ -11078,23 +11269,6 @@
|
||||
"who5": "Un score de bien-être de 50 ou moins peut mériter un regard plus attentif. Si tu le souhaites, le PHQ-9 sur cette page explore l'humeur plus en détail. C'est une suggestion bienveillante, pas un diagnostic.",
|
||||
"sci": "Dans l'étude de ce questionnaire, des scores de 16 ou moins étaient associés à une insomnie probable. Si un mauvais sommeil continue de te peser, parler à un professionnel de santé peut aider. Ce n'est pas un diagnostic."
|
||||
},
|
||||
"validatedInEnglishNote": "Ce questionnaire est validé en anglais ; les questions sont affichées dans leur version originale anglaise.",
|
||||
"who5Options": {
|
||||
"0": "Jamais",
|
||||
"1": "De temps en temps",
|
||||
"2": "Moins de la moitié du temps",
|
||||
"3": "Plus de la moitié du temps",
|
||||
"4": "La plupart du temps",
|
||||
"5": "Tout le temps"
|
||||
},
|
||||
"stems": {
|
||||
"gad7": {
|
||||
"period": "Au cours des 2 dernières semaines, selon quelle fréquence avez-vous été gêné(e) par les problèmes suivants ?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "Au cours des deux dernières semaines"
|
||||
}
|
||||
},
|
||||
"openDetail": "Voir les détails",
|
||||
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.",
|
||||
@@ -11144,6 +11318,13 @@
|
||||
"belowThreshold": "Votre sommeil du dernier mois se situait dans la zone où l’étude à l’origine de ce questionnaire jugeait une insomnie probable.",
|
||||
"aboveThreshold": "Votre sommeil du dernier mois se situait en dehors de la zone que cette étude associe à l’insomnie."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Cette traduction n’a pas été validée scientifiquement ; considérez le résultat comme une indication.",
|
||||
"showOriginal": "Afficher le texte original en anglais",
|
||||
"showTranslation": "Afficher la traduction",
|
||||
"resultUnvalidated": "Réponses données dans une traduction qui n’a pas été validée scientifiquement ; considérez le résultat comme une indication.",
|
||||
"historyUnvalidated": "Réponses données dans une traduction non validée scientifiquement"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11671,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Déjà enregistré. Le document existant est mis en évidence.",
|
||||
"deleted": "Document supprimé",
|
||||
"restoreFailed": "Impossible de restaurer le document."
|
||||
"restoreFailed": "Impossible de restaurer le document.",
|
||||
"deletedHint": "Les documents supprimés restent {days} jours dans Supprimés récemment."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Chargement…",
|
||||
@@ -11716,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Impossible de charger ce document.",
|
||||
@@ -11788,7 +11971,7 @@
|
||||
"dismiss": "Ignorer",
|
||||
"empty": "Impossible de lire ce document.",
|
||||
"unavailableBody": "Configurez un fournisseur d'IA pour suggérer un titre, un type et une date.",
|
||||
"unavailableLocalOcr": "Activez l'OCR local dans les paramètres d'IA pour lire les images sur cet appareil.",
|
||||
"unavailableLocalOcr": "Activez « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses pour lire les images sur cet appareil.",
|
||||
"unavailableAction": "Ouvrir les paramètres d'IA",
|
||||
"errorGeneric": "Impossible de lire le document. Réessayez.",
|
||||
"errorOcr": "Impossible de lire le texte de cette image.",
|
||||
@@ -11800,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
|
||||
"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.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses.",
|
||||
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
|
||||
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier.",
|
||||
"errorConsent": "La lecture de documents par IA nécessite d'abord votre consentement.",
|
||||
"errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment."
|
||||
"errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment.",
|
||||
"unavailableLocalOcrAction": "Ouvrir les paramètres des analyses"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Résumer",
|
||||
@@ -11860,6 +12045,7 @@
|
||||
"extracting": "Extraction…",
|
||||
"extractHint": "Utilise le texte déjà lu de ce document. Les valeurs extraites attendent votre vérification ; rien n’est enregistré automatiquement.",
|
||||
"extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
|
||||
"extractIncomplete": "Une partie du document n’a pas pu être lue entièrement, des valeurs peuvent donc manquer. Comparez la liste avec le document.",
|
||||
"failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
|
||||
"failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée.",
|
||||
@@ -11907,6 +12093,7 @@
|
||||
"loadMore": "Charger plus",
|
||||
"stateImported": "Dans HealthLog",
|
||||
"stateDeleted": "Supprimé dans HealthLog",
|
||||
"stateInTrash": "Dans Supprimés récemment",
|
||||
"selectedCount": "{count} sélectionnés sur {max} au plus",
|
||||
"importAction": "Importer {count}",
|
||||
"importing": "Import en cours, un document après l'autre…",
|
||||
@@ -11920,7 +12107,9 @@
|
||||
"importedLinked": "Importé et lié",
|
||||
"duplicate": "Déjà dans HealthLog",
|
||||
"duplicateLinked": "Déjà dans HealthLog, désormais lié",
|
||||
"deleted": "Supprimé dans HealthLog, laissé tel quel"
|
||||
"deleted": "Supprimé dans HealthLog, laissé tel quel",
|
||||
"restored": "Restauré depuis Supprimés récemment",
|
||||
"restoredLinked": "Restauré depuis Supprimés récemment et lié"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Les archives de documents ne sont pas activées sur ce serveur.",
|
||||
@@ -11964,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Supprimer ce document ?",
|
||||
"titleMany": "Supprimer {count} documents ?",
|
||||
"body": "Vous pouvez annuler juste après. Au bout de 30 jours, la suppression est définitive.",
|
||||
"body": "Les documents supprimés restent {days} jours dans Supprimés récemment, puis ils sont supprimés définitivement.",
|
||||
"action": "Supprimer"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Supprimés récemment",
|
||||
"subtitle": "Les documents supprimés restent ici {days} jours, puis ils sont supprimés définitivement.",
|
||||
"back": "Documents",
|
||||
"entryLabel": "Supprimés récemment ({count})",
|
||||
"loadError": "Impossible de charger Supprimés récemment.",
|
||||
"emptyTitle": "Rien dans Supprimés récemment",
|
||||
"emptyDescription": "Ce que vous supprimez attend ici {days} jours avant d'être supprimé définitivement.",
|
||||
"count": "Documents supprimés ({count})",
|
||||
"restoreAll": "Tout restaurer",
|
||||
"restore": "Restaurer",
|
||||
"purge": "Supprimer maintenant",
|
||||
"deletedOn": "Supprimé le {date}.",
|
||||
"removesIn": "Supprimé définitivement dans {days} jours.",
|
||||
"removesSoon": "Supprimé définitivement dans moins d'un jour.",
|
||||
"restored": "Document restauré",
|
||||
"openRestored": "Ouvrir",
|
||||
"restoreDuplicate": "Un document identique est déjà enregistré, cette copie ne peut donc pas être restaurée.",
|
||||
"restorePartial": "{failed} sur {total} n'ont pas pu être restaurés",
|
||||
"purged": "Document supprimé définitivement",
|
||||
"purgeFailed": "Impossible de supprimer le document.",
|
||||
"purgeConfirmTitle": "Supprimer ce document définitivement ?",
|
||||
"purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12650,7 +12863,8 @@
|
||||
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome.",
|
||||
"methodSso": "{name} se connecte par authentification unique en utilisant le lien.",
|
||||
"methodBoth": "{name} peut définir un mot de passe ou se connecter par authentification unique."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Tout ce qui a été enregistré pour {name} disparaît avec le profil : mesures, médicaments, documents et notes. Le profil est verrouillé immédiatement et effacé au bout de {days} jours. D’ici là, un administrateur de ce serveur peut annuler la suppression."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}"
|
||||
@@ -12705,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} a restauré une entrée du carnet d'adresses",
|
||||
"illnessDayLogUpsert": "{name} a enregistré une journée de maladie",
|
||||
"medicationUpdate": "{name} a modifié un médicament",
|
||||
"medicationCourseEnd": "{name} a terminé un traitement",
|
||||
"medicationDelete": "{name} a supprimé un médicament",
|
||||
"medicationGlp1Update": "{name} a modifié un traitement GLP-1",
|
||||
"medicationScheduleCreate": "{name} a ajouté un schéma de prise",
|
||||
@@ -12752,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé",
|
||||
"rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé",
|
||||
"rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé",
|
||||
"rejectedTrackedValueTypeCreate": "{name} a tenté d'ajouter un type de valeur personnalisée et a été refusé",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} a tenté de modifier un type de valeur personnalisée et a été refusé",
|
||||
"rejectedMedicationLayout": "{name} a tenté de réorganiser la liste des médicaments et a été refusé",
|
||||
"deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file",
|
||||
"anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé",
|
||||
"customMetricCreate": "{name} a ajouté un type de valeur personnalisée",
|
||||
@@ -13061,7 +13279,8 @@
|
||||
"backgroundScan": "Lecture en arrière-plan. Avec un modèle lent, cela peut prendre quelques minutes.",
|
||||
"waitingForWorker": "En attente du traitement en arrière-plan. Si rien ne change, le worker du serveur n'est peut-être pas lancé.",
|
||||
"errorWorkerUnavailable": "Le traitement en arrière-plan n'est pas disponible pour le moment. Réessayez dans un instant ou demandez à la personne qui gère le serveur de le vérifier.",
|
||||
"errorTimedOut": "La lecture a pris plus de temps que votre délai de réponse IA ne le permet. Réessayez ou augmentez ce délai dans les réglages IA."
|
||||
"errorTimedOut": "La lecture a pris plus de temps que votre délai de réponse IA ne le permet. Réessayez ou augmentez ce délai dans les réglages IA.",
|
||||
"errorReaderBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nouveau symptôme",
|
||||
|
||||
+339
-147
@@ -175,6 +175,9 @@
|
||||
"failed": "Non è stato possibile leggere l'etichetta. Prova con una foto più nitida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti",
|
||||
"window": "analizzato dal {from} al {to}, può andare oltre il periodo del report",
|
||||
"windowLine": "Analizzato dal {from} al {to}. Questo intervallo può andare oltre il periodo del report."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} risultati su {total} sono stati ottenuti con una traduzione non validata scientificamente; vanno considerati come orientamento."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Invito non valido",
|
||||
"description": "Questo link di invito è incompleto o è stato copiato male. Chiedi un nuovo link alla persona che ti ha invitato.",
|
||||
"toLogin": "Vai all’accesso"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Questo account verrà eliminato il {date} ed è bloccato fino ad allora. Un amministratore di questo server può annullare l’eliminazione prima di quella data.",
|
||||
"deletionPendingNoDate": "Questo account è in attesa di eliminazione ed è bloccato. Un amministratore di questo server può annullare l’eliminazione finché non è completata."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Fai un backup della tua chiave di cifratura",
|
||||
"body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.",
|
||||
"action": "Apri il passaggio"
|
||||
},
|
||||
"quickAddSymptom": "Registra sintomo",
|
||||
"todayCardVisible": "Mostra la scheda in alto"
|
||||
"todayCardVisible": "Mostra la scheda in alto",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Salva il tuo kit di recupero",
|
||||
"body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.",
|
||||
"action": "Apri il kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Misurazioni",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.",
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.",
|
||||
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
|
||||
"errorReasoningOnly": "Il modello ha solo ragionato senza scrivere una risposta. Riprova oppure scegli un altro modello nelle impostazioni IA.",
|
||||
"errorDemo": "Nella demo il Coach non risponde.",
|
||||
"errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.",
|
||||
"errorNoProviderAction": "Collega un provider IA",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
|
||||
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
|
||||
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
|
||||
"detail": "Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
|
||||
"detail": "Una copia portabile della tua cartella da conservare e leggere: valori in JSON semplice, documenti solo con i metadati. Non è la copia settimanale dell’account che questo server tiene per riportare indietro un account, e non può ricreare un account su un altro server. Alcuni tipi di dati non sono ancora inclusi; il manifest del file indica cosa contiene.",
|
||||
"contents": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "File JSON",
|
||||
"title": "Misurazioni e umore (JSON)",
|
||||
"description": "Incolla JSON o carica un file .json con misurazioni e voci dell'umore.",
|
||||
"pasteLabel": "Incolla JSON",
|
||||
"fileInputLabel": "Scegli un file JSON da importare",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Impossibile leggere il file.",
|
||||
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
|
||||
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate",
|
||||
"droppedLinks": "Importato senza questi tag, perché questo account non ne ha nessuno con quel nome: {keys}",
|
||||
"keptLabels": "Questo account ha già tag o gruppi con queste chiavi e con il proprio nome, quindi sono stati mantenuti: {keys}",
|
||||
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
|
||||
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
|
||||
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore.",
|
||||
"limitsHint": "Fino a {mb} MB e fino a {rows} voci per elenco. Aggiunge voci alla tua cartella; non ripristina un backup completo."
|
||||
},
|
||||
"csv": {
|
||||
"title": "File CSV",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Esporta i tuoi dati o importa da un backup."
|
||||
"subtitle": "Esporta i tuoi dati o importa misurazioni e voci da altre app e file."
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni su questa app",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "Il token viene mostrato una sola volta.",
|
||||
"scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.",
|
||||
"createFailed": "Non è stato possibile creare il token. Riprova.",
|
||||
"namePlaceholder": "Nome, ad es. Home Assistant"
|
||||
"namePlaceholder": "Nome, ad es. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Scrivi le misurazioni in Apple Health",
|
||||
"createHint": "L'app HealthLog sul tuo iPhone aggiungerà quindi le misurazioni di questo token ad Apple Health. Puoi cambiarlo più tardi nell'elenco dei token qui sotto.",
|
||||
"toggleAria": "Scrivi le misurazioni di {name} in Apple Health",
|
||||
"updateFailed": "Non è stato possibile modificare l'impostazione di Apple Health. Riprova.",
|
||||
"unattributed": "Le misurazioni inviate prima che esistesse questa impostazione non si possono associare a un token, quindi restano fuori da Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Token per allenamenti",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Elimina il tuo account insieme a passkey, registro di controllo e sessioni.",
|
||||
"deleteAccountCta": "Elimina account",
|
||||
"deleteAccountConfirmTitle": "Eliminare definitivamente l’account?",
|
||||
"deleteAccountConfirmDescription": "Il tuo account, tutti i dati sanitari, le passkey, le sessioni e le voci di audit verranno eliminati definitivamente. Verrai disconnesso subito dopo.",
|
||||
"deleteAccountConfirmTitleGrace": "Eliminare il tuo account?",
|
||||
"deleteAccountConfirmDescription": "Il tuo account, tutti i dati sanitari, le passkey, le sessioni e le voci di audit verranno eliminati definitivamente. Verrai disconnesso subito dopo. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
|
||||
"deleteAccountSuccess": "Account eliminato, disconnessione in corso.",
|
||||
"deleteAccountFailed": "Impossibile eliminare l’account",
|
||||
"deleteAccountFinal": "Elimina definitivamente",
|
||||
"deleteAccountFinalGrace": "Elimina account",
|
||||
"saved": "Salvato",
|
||||
"savingError": "Errore durante il salvataggio",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Conversazioni con il coach: {days} giorni",
|
||||
"audit": "Registro di sicurezza: {days} giorni",
|
||||
"delivery": "Registro di invio delle notifiche: {days} giorni",
|
||||
"unavailable": "I dettagli di conservazione non sono disponibili al momento."
|
||||
"unavailable": "I dettagli di conservazione non sono disponibili al momento.",
|
||||
"accountDeletion": "Eliminazione del tuo account, di un profilo gestito o di tutti i dati: completata dopo {days} giorni",
|
||||
"accountDeletionImmediate": "Eliminazione del tuo account, di un profilo gestito o di tutti i dati: completata subito"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Elimina o reimposta",
|
||||
"description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.",
|
||||
"backupLag": "L'eliminazione rimuove subito i tuoi dati. Se questo server conserva backup esterni cifrati, le copie dei tuoi dati vengono eliminate da quello spazio subito dopo, o alla successiva esecuzione notturna se non è raggiungibile.",
|
||||
"openAdvanced": "Elimina o reimposta"
|
||||
"openAdvanced": "Elimina o reimposta",
|
||||
"immediateNotice": "L'eliminazione rimuove subito i tuoi dati. Se questo server conserva backup esterni cifrati, le copie dei tuoi dati vengono eliminate da quello spazio subito dopo, o alla successiva esecuzione notturna se non è raggiungibile. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
|
||||
"graceNotice": "Quando elimini il tuo account, viene bloccato subito e cancellato dopo {days} giorni, insieme a ogni copia che questo server ne conserva, incluse le copie cifrate fuori dal server. Fino ad allora un amministratore di questo server può annullare l’eliminazione. Eliminare tutti i dati funziona allo stesso modo: una copia della cartella viene conservata per {days} giorni, così un amministratore può ripristinarla. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Impostazione privacy",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Disattivo",
|
||||
"change": "Cambia in Moduli"
|
||||
},
|
||||
"timezoneUseDevice": "Usa il fuso orario di questo dispositivo ({zone})"
|
||||
"timezoneUseDevice": "Usa il fuso orario di questo dispositivo ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Questa azione elimina tutto ciò che contiene la tua cartella: misurazioni, farmaci, voci dell’umore, risultati di laboratorio, voci del ciclo, documenti, conversazioni con l’IA e ogni connessione di integrazione, insieme a tutto ciò che ne deriva e a tutto quello che avevi eliminato prima. I link di condivisione consegnati a uno studio medico smettono subito di funzionare. Il tuo account resta, insieme alle credenziali con cui accedi e alle impostazioni di visualizzazione come lingua, fuso orario e unità. Una copia della tua cartella viene conservata per {days} giorni, così un amministratore di questo server può ripristinarla fino ad allora. Dopo, l’eliminazione è definitiva.",
|
||||
"deleteAccountCardDetailGrace": "Il tuo account viene bloccato subito e cancellato dopo {days} giorni. Fino ad allora un amministratore di questo server può annullare l’eliminazione.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Non puoi forzare la tua disconnessione",
|
||||
"emptyTitle": "Nessun utente in questa vista",
|
||||
"emptyDescription": "Nessun utente corrisponde al filtro attuale.",
|
||||
"emptyResetFilter": "Mostra tutti gli utenti"
|
||||
"emptyResetFilter": "Mostra tutti gli utenti",
|
||||
"deletionPending": "Eliminazione il {date}",
|
||||
"deletionPendingNoDate": "Eliminazione in attesa",
|
||||
"undoDeletion": "Annulla eliminazione",
|
||||
"undoDeletionTitle": "Annullare l’eliminazione di {name}?",
|
||||
"undoDeletionBody": "{name} ha chiesto di eliminare questo account, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene l’account e tutto il suo contenuto com’era. {name} può accedere di nuovo, ma i token API, i link di condivisione e i dispositivi attendibili restano revocati e vanno configurati di nuovo.",
|
||||
"undoDeletionBodyGuardian": "Una persona responsabile ha eliminato il profilo gestito {name}, che viene cancellato alla fine del periodo di tolleranza. Annullare mantiene il profilo e tutto il suo contenuto com’era, e le persone responsabili possono di nuovo accedervi. I suoi link di condivisione e token restano revocati e vanno configurati di nuovo.",
|
||||
"undoDeletionDone": "Eliminazione di {name} annullata",
|
||||
"undoDeletionFailed": "Impossibile annullare l’eliminazione"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Inviti",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backup",
|
||||
"subtitle": "Istantanee settimanali dei dati per utente archiviate in PostgreSQL.",
|
||||
"description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato. Il download decifra l'istantanea per l'amministratore che la richiede; il testo cifrato resta nel database.",
|
||||
"subtitle": "Copie degli account nel database di questo server, per ripristinare un account qui.",
|
||||
"description": "Ogni account ha una copia settimanale, sostituita ogni domenica, più le copie caricate qui e la copia presa prima di «Elimina tutti i dati». Una copia riporta indietro un account esistente su questo server. Non può ricostruire un server perso: per quello servono un dump del database e le chiavi di cifratura. Le dimensioni indicano la copia cifrata. Il download la decifra per l’amministratore che la chiede; il testo cifrato resta nel database.",
|
||||
"runNow": "Esegui backup ora",
|
||||
"runEnqueued": "Processo di backup messo in coda",
|
||||
"runFailed": "Impossibile mettere in coda il backup",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "I backup vengono eseguiti automaticamente ogni domenica alle 03:00, oppure avviane uno ora per creare la prima istantanea.",
|
||||
"colUser": "Utente",
|
||||
"colType": "Tipo",
|
||||
"typeWeeklyAuto": "Settimanale (auto)",
|
||||
"typeWeeklyAuto": "Copia settimanale dell’account",
|
||||
"typeManual": "Manuale",
|
||||
"colSize": "Dimensione",
|
||||
"colCreatedAt": "Ultima istantanea",
|
||||
"colActions": "Azioni",
|
||||
"retentionLabel": "Conservazione: {days} giorni",
|
||||
"scheduleStaleTitle": "Il backup settimanale si è fermato",
|
||||
"scheduleStaleDescription": "La copia pianificata più recente ha {days} giorni. Un'esecuzione settimanale funzionante non ne lascia mai una più vecchia di {threshold} giorni.",
|
||||
"scheduleFailedTitle": "L'ultima esecuzione pianificata del backup non è riuscita",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Download non riuscito",
|
||||
"downloadStarted": "Download avviato",
|
||||
"uploadTitle": "Carica backup",
|
||||
"uploadDescription": "Convalida un file di backup e archivialo come istantanea manuale. Il ripristino è un passaggio separato.",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.",
|
||||
"uploadButton": "Carica file",
|
||||
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
|
||||
"uploadSuccess": "Backup caricato ({count} record)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Impostazione dell'account",
|
||||
"restoreMissingTitle": "Ripristino rifiutato: il file è incompleto",
|
||||
"restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.",
|
||||
"restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.",
|
||||
"restoreMissingRemedy": "Usa una delle copie dell’account di questo server o una copia fuori dal server, oppure esporta di nuovo l’account e carica il nuovo file. Una sezione che il manifest dichiara omessa è un’altra cosa e si ripristina normalmente.",
|
||||
"restoreMissingDocuments": "Documenti",
|
||||
"restoreMissingWorkouts": "Allenamenti",
|
||||
"restoreMissingMentalHealth": "Questionari sulla salute mentale",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Per questo account è già in corso un ripristino. Aspetta che finisca.",
|
||||
"restoreJobQueuedTitle": "Il ripristino di {username} è in attesa di partire",
|
||||
"restoreJobQueuedNote": "Parte appena il processo in background lo prende in carico.",
|
||||
"restoreJobWaitingBackupNote": "In attesa del backup in corso sul server. Un ripristino non gira mai insieme a un backup e parte circa un minuto dopo la sua fine.",
|
||||
"restoreJobWaitingBusyNote": "In attesa che finisca un lavoro pesante sul server, come un’importazione o un’elaborazione notturna. Parte circa un minuto dopo.",
|
||||
"restoreJobQueuedSince": "In coda dalle {when}.",
|
||||
"restoreJobRunningTitle": "Ripristino di {username} in corso",
|
||||
"restoreJobRunningNote": "L'account continua a mostrare i dati attuali finché il ripristino non termina. Puoi lasciare questa pagina: il ripristino prosegue sul server.",
|
||||
"restoreJobResumed": "Riavviato dopo il riavvio del server.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "Il file nel backup appartiene a un account diverso da quello della riga di backup.",
|
||||
"restoreFailureOwnerMissing": "L'account a cui appartiene questo backup non esiste più.",
|
||||
"restoreFailureSectionMissing": "Il file è incompleto: il suo manifesto nomina una sezione che non contiene.",
|
||||
"restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un'esportazione portabile. Usa un backup di disaster recovery.",
|
||||
"restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un’esportazione portabile. Usa invece una delle copie dell’account di questo server o una copia fuori dal server.",
|
||||
"restoreFailureTimeBudget": "Questo backup è troppo grande per essere ripristinato entro il tempo concesso al processo in background.",
|
||||
"restoreFailureTransaction": "Il database ha rifiutato il ripristino a metà, quindi tutto è stato annullato. Il log del server indica l'errore.",
|
||||
"restoreFailureCauseTimeout": "Il ripristino è durato più di quanto il database consente, quindi tutto è stato annullato. Se l'host del database era sotto carico, riavvialo quando è più tranquillo. Il log del server indica il passaggio.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)",
|
||||
"restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)",
|
||||
"typePreWipe": "Prima dell’eliminazione di tutti i dati",
|
||||
"retentionNote": "La copia settimanale viene sostituita ogni domenica. Una copia presa prima di «Elimina tutti i dati» viene eliminata dopo {days} giorni. Le copie caricate restano finché il loro account non viene cancellato.",
|
||||
"retentionNoteNoGrace": "La copia settimanale viene sostituita ogni domenica. Le copie caricate restano finché il loro account non viene cancellato.",
|
||||
"database": {
|
||||
"title": "Dump del database",
|
||||
"lastGood": "Ultimo dump valido {when}, {size}, {where}.",
|
||||
"whereBoth": "conservato su questo server e fuori sede",
|
||||
"whereLocal": "conservato su questo server",
|
||||
"whereOffhost": "solo fuori sede",
|
||||
"none": "Ancora nessun dump del database. Il primo viene eseguito stanotte.",
|
||||
"failedTitle": "L'ultimo dump del database non è riuscito",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "I dump del database sono disattivati (DB_BACKUP_ENABLED=false). Esegui il backup del database in un altro modo.",
|
||||
"noDirectoryOffhost": "Questo server non può scrivere in {dir}, quindi i dump vanno solo fuori sede e qui non ne resta nessuno. Monta il volume dei backup.",
|
||||
"noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup.",
|
||||
"retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} dump notturni più recenti, e il dump fatto prima dell'ultimo aggiornamento resta fino al successivo.",
|
||||
"retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.",
|
||||
"retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.",
|
||||
"notPersistentOffhost": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria), quindi un dump scritto lì andrebbe perso al prossimo aggiornamento. I dump vanno solo fuori sede: qui non ne resta nessuno e prima di una migrazione non ne viene fatto nessuno. Monta il volume dei backup.",
|
||||
"notPersistent": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria) e non è configurato alcun bucket esterno, quindi nessun dump del database viene conservato da nessuna parte. Monta il volume dei backup.",
|
||||
"offhostFailedTitle": "Il dump più recente del database non è arrivato nel bucket esterno",
|
||||
"offhostLastUpload": "Il dump più recente nel bucket è del {when}.",
|
||||
"offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Nascondi la tabella",
|
||||
"columnsShowAll": "Mostra tutte le {count} colonne",
|
||||
"columnsShowWithRows": "Solo colonne con righe",
|
||||
"columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe."
|
||||
"columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe.",
|
||||
"backupsDumpLocalLine": "Chiave {key}: {count} dump del database su questo server sono stati scritti con essa; serve finché la conservazione non li rimuove.",
|
||||
"backupsDumpOffhostLine": "Chiave {key}: il dump del database più recente scritto con essa è entrato nel bucket il {when}; serve lì fino al {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Chiave {key}: il dump del database più recente scritto con essa è entrato nel bucket il {when}. Non si sa per quanto il bucket conservi database/, quindi conserva la chiave finché non sai che quel dump non c'è più."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Manutenzione della tabella delle misurazioni",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Questa parte della pagina non è stata mostrata",
|
||||
"description": "Il resto della pagina funziona; riprova o ricarica se il problema continua."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Fai un backup della tua chiave di cifratura",
|
||||
"description": "Senza una copia di questa chiave, i tuoi dati cifrati e i backup di questo server non saranno più leggibili.",
|
||||
"loading": "Caricamento dello stato del backup della chiave…",
|
||||
"loadError": "Impossibile caricare lo stato del backup della chiave.",
|
||||
"badgeDue": "Da fare",
|
||||
"badgeDone": "Salvata",
|
||||
"keyId": "ID della chiave attiva",
|
||||
"fingerprint": "Impronta",
|
||||
"whatIsLost": "Conserva una copia della chiave fuori da questo server, per esempio in un gestore di password. Se il server viene reinstallato con un'altra chiave, HealthLog non torna in servizio finché la chiave originale non è di nuovo al suo posto.",
|
||||
"fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 della chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.",
|
||||
"checkLabel": "Verifica la mia copia",
|
||||
"checkButton": "Verifica",
|
||||
"checkNote": "La chiave incollata viene confrontata con quella attiva e poi scartata. Non viene mai salvata né registrata.",
|
||||
"checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.",
|
||||
"checkMatches": "La tua copia corrisponde alla chiave attiva.",
|
||||
"checkNoMatch": "La tua copia non corrisponde alla chiave attiva. Controlla di aver copiato l'intero valore.",
|
||||
"checkFailed": "La verifica non è riuscita.",
|
||||
"confirmButton": "Ho salvato questa chiave",
|
||||
"confirmed": "Backup della chiave confermato.",
|
||||
"confirmFailed": "Impossibile salvare la conferma.",
|
||||
"stale": "La chiave del server è cambiata da quando questa pagina è stata caricata. Controlla il nuovo ID e l'impronta, poi conferma di nuovo.",
|
||||
"confirmedAt": "Confermato il {date}.",
|
||||
"confirmedAtBy": "Confermato il {date} da {who}.",
|
||||
"dueAgain": "È stato confermato un backup per la chiave {keyId}, ma ora il server usa un'altra chiave. Salva quella attuale.",
|
||||
"tileLabel": "Backup della chiave di cifratura",
|
||||
"tileDue": "Non confermato",
|
||||
"tileDone": "Confermato",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog, scegli Modifica e copia ENCRYPTION_KEY dalle variabili d'ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY dalle sue variabili d'ambiente."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog non riesce ad aprire i suoi dati",
|
||||
"body": "La chiave di cifratura con cui è stato avviato questo server non è quella con cui è stato scritto il suo database. Per proteggere i tuoi dati, HealthLog non serve nulla finché il problema non è risolto.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.",
|
||||
"gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)",
|
||||
"gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit di recupero",
|
||||
"description": "Ogni chiave necessaria per ripristinare questo server, con ID e impronta.",
|
||||
"intro": "Conserva una copia di ciascuna chiave qui sotto fuori da questo server, per esempio in un gestore di password. Senza di esse non si possono più leggere né un dump del database né una copia di backup, e HealthLog si rifiuta di avviarsi se manca una chiave di cui ha bisogno.",
|
||||
"loading": "Caricamento del kit di recupero…",
|
||||
"loadError": "Impossibile caricare il kit di recupero.",
|
||||
"badgeDue": "Da fare",
|
||||
"badgeDone": "Salvato",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Impronta",
|
||||
"noFingerprint": "Non presente su questo server",
|
||||
"kind": {
|
||||
"application": "Chiave dell'applicazione {id}",
|
||||
"backup": "Chiave dei backup fuori host",
|
||||
"backupPrevious": "Chiave precedente dei backup fuori host",
|
||||
"token": "Chiave dei token"
|
||||
},
|
||||
"need": {
|
||||
"active": "Cifra i nuovi dati.",
|
||||
"configured": "Presente su questo server.",
|
||||
"storedCopies": "Serve a {count} copie archiviate.",
|
||||
"offhostUntil": "Serve alle copie fuori host fino al {date}.",
|
||||
"offhostUnknown": "Serve alle copie fuori host finché il bucket le conserva.",
|
||||
"backupActive": "Sigilla ogni nuova copia fuori host.",
|
||||
"backupPrevious": "Apre le copie fuori host scritte prima dell'ultimo cambio di chiave.",
|
||||
"token": "Firma sessioni, token e link di condivisione. Senza di essa tutti vengono disconnessi, ma nessun dato va perso.",
|
||||
"localDumps": "Serve a {count} dump del database su questo server.",
|
||||
"offhostDumpsUntil": "Serve ai dump del database fuori host fino al {date}.",
|
||||
"offhostDumpsUnknown": "Serve ai dump del database fuori host finché il bucket li conserva."
|
||||
},
|
||||
"source": "Impostata in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Salvata",
|
||||
"new": "Non ancora salvata",
|
||||
"notConfigured": "Non presente su questo server",
|
||||
"unreadable": "Chiave non valida"
|
||||
},
|
||||
"notConfiguredHint": "Un backup ha ancora bisogno di questa chiave e questo server non ce l'ha. Solo una copia che conservi altrove può leggere quei backup. Conferma il kit quando hai quella copia.",
|
||||
"unreadableHint": "La variabile è impostata ma non contiene una chiave valida. Correggila prima di fare affidamento sulle copie fuori host.",
|
||||
"fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 di una chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.",
|
||||
"whereTitle": "Dove sono impostate le chiavi",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Le chiavi sono nel file .env accanto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, con le copie fuori host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog e scegli Edit. Le chiavi sono sotto HealthLog Configuration; in un'app installata tramite YAML sono ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nelle impostazioni dell'ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue variabili d'ambiente."
|
||||
},
|
||||
"restoreTitle": "Ripristinare questo server",
|
||||
"restoreBody": "Installa HealthLog con le stesse chiavi, ripristina il dump del database più recente in un database vuoto, poi avvia l'app. La guida ti accompagna passo per passo.",
|
||||
"restoreLink": "Guida al ripristino dopo un disastro",
|
||||
"checkLabel": "Verifica la mia copia",
|
||||
"checkKeyLabel": "Chiave da verificare",
|
||||
"checkValueLabel": "La tua copia della chiave",
|
||||
"checkButton": "Verifica",
|
||||
"checkNote": "La chiave incollata viene confrontata con quella presente su questo server e poi scartata. Non viene mai salvata né registrata.",
|
||||
"checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.",
|
||||
"checkMatches": "La tua copia corrisponde a questa chiave.",
|
||||
"checkNoMatch": "La tua copia non corrisponde a questa chiave. Controlla di aver copiato l'intero valore.",
|
||||
"checkFailed": "La verifica non è riuscita.",
|
||||
"confirmButton": "Ho una copia di ogni chiave",
|
||||
"confirmed": "Kit di recupero confermato.",
|
||||
"confirmFailed": "Impossibile salvare la conferma.",
|
||||
"stale": "Il kit è cambiato da quando questa pagina è stata caricata. Controlla le chiavi che elenca ora, poi conferma di nuovo.",
|
||||
"confirmedAt": "Confermato il {date}.",
|
||||
"confirmedAtBy": "Confermato il {date} da {who}.",
|
||||
"dueAgain": "Il kit è stato confermato il {date}. Ora elenca anche {keys}, che quella conferma non copriva. Salva una copia di ciascuna e conferma di nuovo."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Preparazione al ripristino",
|
||||
"description": "Se questo server potrebbe essere rimesso in piedi dopo una perdita, valutato in base a ogni backup che esegue.",
|
||||
"loading": "Controllo dei backup…",
|
||||
"loadError": "Impossibile caricare la preparazione al ripristino.",
|
||||
"state": {
|
||||
"ok": "Pronto",
|
||||
"attention": "Richiede attenzione",
|
||||
"failing": "A rischio"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump del database",
|
||||
"weekly_copy": "Copie settimanali degli account",
|
||||
"offhost_copies": "Copie fuori host",
|
||||
"restore_drill": "Prova di ripristino",
|
||||
"offhost_setup": "Backup fuori host",
|
||||
"recovery_kit": "Kit di recupero",
|
||||
"dump_retention": "Dump nel bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump più recente: {date}",
|
||||
"weekly_copy": "Copia di account più vecchia: {date}",
|
||||
"offhost_copies": "Copia fuori host meno recente: {date}",
|
||||
"restore_drill": "Ultima prova: {date}",
|
||||
"recovery_kit": "Confermato: {date}"
|
||||
},
|
||||
"dueBy": "Scadenza: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Apri i backup",
|
||||
"weekly_copy": "Apri i backup",
|
||||
"offhost_copies": "Apri i backup",
|
||||
"restore_drill": "Apri i backup",
|
||||
"offhost_setup": "Apri i backup",
|
||||
"recovery_kit": "Apri il kit",
|
||||
"dump_retention": "Apri i backup"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Questa versione non pianifica un dump del database.",
|
||||
"disabled": "Il dump pianificato del database è disattivato, quindi nessuna copia può ricostruire questo server. Per riattivarlo, rimuovi DB_BACKUP_ENABLED o impostalo su true, poi riavvia HealthLog.",
|
||||
"unreadable": "Non è stato possibile leggere lo stato del dump del database.",
|
||||
"never_succeeded": "Nessun dump del database è ancora riuscito, e l'ultimo tentativo non è andato a buon fine.",
|
||||
"never_ran": "Nelle {hours} ore trascorse da quando i dump sono stati attivati non è stato scritto alcun dump del database.",
|
||||
"first_pending": "Il primo dump del database non è ancora stato eseguito.",
|
||||
"stale": "Il dump del database più recente ha {hours} ore.",
|
||||
"last_failed": "L'ultimo dump del database non è riuscito. Quello precedente è ancora recente.",
|
||||
"fresh": "L'ultimo dump del database è recente.",
|
||||
"nowhere_to_write": "La cartella dei dump {directory} non è scrivibile e non è configurato nessun bucket esterno, quindi non si può conservare alcun dump.",
|
||||
"not_persistent": "La cartella dei dump {directory} non è su uno spazio persistente e non è configurato alcun bucket esterno, quindi ogni dump andrebbe perso al prossimo aggiornamento. Monta il volume dei backup.",
|
||||
"fresh_offhost_only": "Il dump del database più recente è aggiornato e si trova nel bucket esterno. Su questo server non ne viene conservato nessuno, perché {directory} non è su uno spazio persistente, e prima di una migrazione non ne viene fatto nessuno.",
|
||||
"offhost_failed": "Il dump più recente del database è conservato su questo server, ma il caricamento nel bucket esterno non è riuscito.",
|
||||
"offhost_stale": "Da {hours} ore nessun dump del database è arrivato nel bucket esterno: i caricamenti continuano a non riuscire. Se questo server va perso, non c'è un dump recente da cui ricostruirlo.",
|
||||
"offhost_never": "Nessun dump del database è mai arrivato nel bucket esterno: il caricamento non riesce. Se questo server va perso, non c'è alcun dump da cui ricostruirlo."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Non ci sono ancora account da copiare.",
|
||||
"stale": "La copia settimanale più vecchia ha {days} giorni. Account in ritardo: {accounts}.",
|
||||
"never": "Un account non ha una copia settimanale da {days} giorni. Account in ritardo: {accounts}.",
|
||||
"first_pending": "La prima copia settimanale viene creata domenica mattina presto.",
|
||||
"fresh": "Ogni account ha una copia dell'ultima settimana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Non è configurato alcun bucket fuori host.",
|
||||
"stale": "Le copie fuori host sono indietro di più di due notti. Account in ritardo: {accounts}.",
|
||||
"first_pending": "Il giro notturno fuori host non ha ancora raggiunto alcun account.",
|
||||
"fresh": "Ogni account ha una copia recente nel bucket fuori host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "La prova legge il bucket fuori host, che non è configurato.",
|
||||
"not_run_yet": "La prova di ripristino mensile non è ancora stata eseguita.",
|
||||
"failed": "L'ultima prova di ripristino non è riuscita: {failed} copie su {checked} non si sono potute rileggere.",
|
||||
"failed_run": "L'ultima prova di ripristino non è riuscita ad arrivare in fondo.",
|
||||
"passed": "L'ultima prova di ripristino ha riletto {checked} copie.",
|
||||
"nothing_checked": "L'ultima prova di ripristino si è fermata al suo limite di tempo prima di rileggere una sola copia, quindi non ha dimostrato nulla."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Le copie lasciano questo server ogni notte.",
|
||||
"no_admin": "Non esiste ancora un account amministratore.",
|
||||
"not_configured": "Non è configurato alcun bucket fuori host, quindi ogni copia si trova solo su questo server.",
|
||||
"not_configured_grace": "Non è ancora configurato alcun bucket fuori host, quindi ogni copia si trova solo su questo server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Esiste una copia di ogni chiave necessaria per un ripristino.",
|
||||
"out_of_date": "Il kit di ripristino ora elenca anche {keys}, che l'ultima conferma non copriva. Non c'è nulla di rotto: salva una copia e conferma di nuovo il kit.",
|
||||
"no_admin": "Non esiste ancora un account amministratore.",
|
||||
"not_confirmed": "Nessuno ha confermato una copia delle chiavi necessarie per un ripristino: {keys}.",
|
||||
"not_confirmed_grace": "Salva una copia delle chiavi necessarie per un ripristino: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Non è configurato alcun bucket esterno.",
|
||||
"no_offhost_dumps": "Nessun dump del database va nel bucket.",
|
||||
"missing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.",
|
||||
"configured": "Il bucket rimuove i dump dopo {days} giorni.",
|
||||
"unknown": "Il bucket non dice se una regola rimuove i dump vecchi. Verifica che una regola di ciclo di vita copra database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Il dump del database è di nuovo recente.",
|
||||
"weekly_copy": "Ogni account ha di nuovo una copia settimanale recente.",
|
||||
"offhost_copies": "Ogni account ha di nuovo una copia fuori host recente.",
|
||||
"restore_drill": "La prova di ripristino è di nuovo riuscita.",
|
||||
"offhost_setup": "Le copie fuori host ora sono configurate.",
|
||||
"recovery_kit": "Il kit di recupero è di nuovo confermato.",
|
||||
"dump_retention": "Ora una regola di ciclo di vita del bucket copre i dump del database."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati",
|
||||
"offlineGeoUnavailableBody": "HealthLog risolve le posizioni di accesso tramite la ricerca online su {host}, quindi ogni IP di accesso lascia questo server. Per risolverle localmente, scarica i database GeoLite2-City e GeoLite2-ASN con un account MaxMind gratuito, monta la directory nel container e fai puntare GEOLITE2_DIR ad essa. Passaggi: {docsUrl}",
|
||||
"tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}",
|
||||
"tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Impossibile cancellare l’account: {username}",
|
||||
"accountErasureRefusedBody": "Il periodo di eliminazione di {username} è terminato, ma l’account è l’ultima persona responsabile attiva di un profilo gestito, quindi non è stato cancellato. Resta bloccato. Aggiungi un’altra persona responsabile al profilo, elimina il profilo o annulla l’eliminazione in Administration, Utenti.",
|
||||
"accountErasureFailedBody": "Il periodo di eliminazione di {username} è terminato, ma la cancellazione dell’account non è riuscita. Resta bloccato e viene ritentata ogni ora, dopo gli altri account in scadenza. L’errore è nella scheda dei job non riusciti in Administration."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Promemoria dei controlli",
|
||||
"description": "Attiva o disattiva i singoli promemoria dei controlli."
|
||||
},
|
||||
"eventBackupHealth": "Stato dei backup",
|
||||
"eventBackupHealthDesc": "Quando un backup si ferma, resta indietro o torna a funzionare. Inviato solo agli amministratori.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "I backup richiedono attenzione",
|
||||
"titleFailing": "I backup sono a rischio",
|
||||
"titleReminder": "I backup sono ancora a rischio",
|
||||
"titleRecovered": "I backup sono tornati alla normalità"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,25 +11162,6 @@
|
||||
"who5": "Test sul benessere, 5 domande",
|
||||
"sci": "Test sul sonno, 8 domande"
|
||||
},
|
||||
"options": {
|
||||
"0": "Mai",
|
||||
"1": "Alcuni giorni",
|
||||
"2": "Per più della metà dei giorni",
|
||||
"3": "Quasi ogni giorno"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Mai",
|
||||
"1": "Alcuni giorni",
|
||||
"2": "Per oltre la metà dei giorni",
|
||||
"3": "Quasi ogni giorno"
|
||||
},
|
||||
"functionalTitle": "Se ha fatto una crocetta su uno qualsiasi di questi problemi, quanto questi problemi le hanno reso difficile fare il suo lavoro, occuparsi delle sue cose a casa o avere buoni rapporti con gli altri?",
|
||||
"functional": {
|
||||
"0": "Per niente difficile",
|
||||
"1": "Abbastanza difficile",
|
||||
"2": "Molto difficile",
|
||||
"3": "Estremamente difficile"
|
||||
},
|
||||
"functionalOptionalHint": "Facoltativo: puoi inviare anche senza rispondere a questa domanda.",
|
||||
"result": {
|
||||
"title": "Il tuo risultato",
|
||||
@@ -11015,35 +11211,6 @@
|
||||
"ifDanger": "Se sei in pericolo immediato, chiama subito il {emergency}.",
|
||||
"resourcesTitle": "Persone che possono aiutare"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Scarso interesse o piacere nel fare le cose",
|
||||
"2": "Sentirsi giù, triste o disperato/a",
|
||||
"3": "Problemi ad addormentarsi o a dormire tutta la notte senza svegliarsi, o a dormire troppo",
|
||||
"4": "Sentirsi stanco/a o avere poca energia",
|
||||
"5": "Scarso appetito o mangiare troppo",
|
||||
"6": "Avere una scarsa opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari",
|
||||
"7": "Difficoltà a concentrarsi su qualcosa, per esempio leggere il giornale o guardare la televisione",
|
||||
"8": "Muoversi o parlare così lentamente da poter essere notato/a da altre persone. O, al contrario, essere così irrequieto/a da muoversi molto più del solito",
|
||||
"9": "Pensare che sarebbe meglio morire o farsi del male in un modo o nell’altro"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Sentirsi nervoso/a, ansioso/a o teso/a",
|
||||
"2": "Non riuscire a smettere di preoccuparsi o a tenere sotto controllo le preoccupazioni",
|
||||
"3": "Preoccuparsi troppo per varie cose",
|
||||
"4": "Avere difficoltà a rilassarsi",
|
||||
"5": "Essere talmente irrequieto/a da far fatica a stare seduto/a fermo/a",
|
||||
"6": "Infastidirsi o irritarsi facilmente",
|
||||
"7": "Avere paura che possa succedere qualcosa di terribile"
|
||||
},
|
||||
"who5": {
|
||||
"1": "Mi sono sentito/a allegro/a e di buon umore",
|
||||
"2": "Mi sono sentito/a calmo/a e rilassato/a",
|
||||
"3": "Mi sono sentito/a attivo/a ed energico/a",
|
||||
"4": "Mi sono svegliato/a sentendomi fresco/a e riposato/a",
|
||||
"5": "La mia vita di tutti i giorni è stata piena di cose che mi interessano"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, gratuito e riservato)"
|
||||
@@ -11102,26 +11269,6 @@
|
||||
"who5": "Un punteggio di benessere pari o inferiore a 50 può meritare uno sguardo più attento. Se vuoi, il PHQ-9 in questa pagina esplora l'umore più in dettaglio. È un suggerimento gentile, non una diagnosi.",
|
||||
"sci": "Nello studio di questo questionario, punteggi pari o inferiori a 16 erano associati a insonnia probabile. Se dormire male continua a pesarti, parlare con un professionista sanitario può aiutare. Non è una diagnosi."
|
||||
},
|
||||
"validatedInEnglishNote": "Questo questionario è validato in inglese; le domande sono mostrate nella versione originale inglese.",
|
||||
"who5Options": {
|
||||
"0": "Mai",
|
||||
"1": "A volte",
|
||||
"2": "Meno della metà del tempo",
|
||||
"3": "Più della metà del tempo",
|
||||
"4": "La maggior parte del tempo",
|
||||
"5": "Sempre"
|
||||
},
|
||||
"stems": {
|
||||
"phq9": {
|
||||
"period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?"
|
||||
},
|
||||
"gad7": {
|
||||
"period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "Nelle ultime due settimane"
|
||||
}
|
||||
},
|
||||
"openDetail": "Mostra dettagli",
|
||||
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.",
|
||||
@@ -11171,6 +11318,13 @@
|
||||
"belowThreshold": "Il tuo sonno nell’ultimo mese era nella fascia in cui lo studio alla base di questo questionario riteneva probabile un’insonnia.",
|
||||
"aboveThreshold": "Il tuo sonno nell’ultimo mese era fuori dalla fascia che quello studio collega all’insonnia."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Questa traduzione non è stata validata scientificamente; considera il risultato come un orientamento.",
|
||||
"showOriginal": "Mostra il testo originale in inglese",
|
||||
"showTranslation": "Mostra la traduzione",
|
||||
"resultUnvalidated": "Risposte date in una traduzione non validata scientificamente; considera il risultato come un orientamento.",
|
||||
"historyUnvalidated": "Risposte date in una traduzione non validata scientificamente"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11698,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Già archiviato. Il documento esistente è evidenziato.",
|
||||
"deleted": "Documento eliminato",
|
||||
"restoreFailed": "Impossibile ripristinare il documento."
|
||||
"restoreFailed": "Impossibile ripristinare il documento.",
|
||||
"deletedHint": "I documenti eliminati restano in Eliminati di recente per {days} giorni."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Caricamento…",
|
||||
@@ -11743,7 +11898,8 @@
|
||||
"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ì.",
|
||||
"empty-transcription": "Il provider IA non ha restituito testo per questo documento."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Impossibile caricare questo documento.",
|
||||
@@ -11815,7 +11971,7 @@
|
||||
"dismiss": "Ignora",
|
||||
"empty": "Impossibile leggere questo documento.",
|
||||
"unavailableBody": "Configura un provider IA per suggerire titolo, tipo e data.",
|
||||
"unavailableLocalOcr": "Attiva l'OCR locale nelle impostazioni IA per leggere le immagini su questo dispositivo.",
|
||||
"unavailableLocalOcr": "Attiva «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami per leggere le immagini su questo dispositivo.",
|
||||
"unavailableAction": "Apri impostazioni IA",
|
||||
"errorGeneric": "Impossibile leggere il documento. Riprova.",
|
||||
"errorOcr": "Impossibile leggere il testo da questa immagine.",
|
||||
@@ -11827,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
|
||||
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
|
||||
"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.",
|
||||
"errorLocalOcrDisabled": "Attiva prima «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami.",
|
||||
"errorProvider": "Nessun provider IA è configurato per questo.",
|
||||
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file.",
|
||||
"errorConsent": "La lettura dei documenti con IA richiede prima il tuo consenso.",
|
||||
"errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento."
|
||||
"errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento.",
|
||||
"unavailableLocalOcrAction": "Apri le impostazioni degli esami"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Riassumi",
|
||||
@@ -11887,6 +12045,7 @@
|
||||
"extracting": "Estrazione…",
|
||||
"extractHint": "Usa il testo già letto da questo documento. I valori estratti attendono la tua revisione; nulla viene salvato automaticamente.",
|
||||
"extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
|
||||
"extractIncomplete": "Una parte del documento non è stata letta per intero, quindi possono mancare dei valori. Confronta l'elenco con il documento.",
|
||||
"failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
|
||||
"failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
|
||||
"failGeneric": "Questo valore non è stato salvato.",
|
||||
@@ -11934,6 +12093,7 @@
|
||||
"loadMore": "Carica altri",
|
||||
"stateImported": "Già in HealthLog",
|
||||
"stateDeleted": "Eliminato in HealthLog",
|
||||
"stateInTrash": "In Eliminati di recente",
|
||||
"selectedCount": "{count} selezionati su un massimo di {max}",
|
||||
"importAction": "Importa {count}",
|
||||
"importing": "Importazione in corso, un documento alla volta…",
|
||||
@@ -11947,7 +12107,9 @@
|
||||
"importedLinked": "Importato e collegato",
|
||||
"duplicate": "Già in HealthLog",
|
||||
"duplicateLinked": "Già in HealthLog, ora collegato",
|
||||
"deleted": "Eliminato in HealthLog, lasciato così"
|
||||
"deleted": "Eliminato in HealthLog, lasciato così",
|
||||
"restored": "Ripristinato da Eliminati di recente",
|
||||
"restoredLinked": "Ripristinato da Eliminati di recente e collegato"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Gli archivi di documenti non sono abilitati su questo server.",
|
||||
@@ -11991,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Eliminare questo documento?",
|
||||
"titleMany": "Eliminare {count} documenti?",
|
||||
"body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.",
|
||||
"body": "I documenti eliminati restano in Eliminati di recente per {days} giorni, poi vengono rimossi definitivamente.",
|
||||
"action": "Elimina"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Eliminati di recente",
|
||||
"subtitle": "I documenti eliminati restano qui per {days} giorni, poi vengono rimossi definitivamente.",
|
||||
"back": "Documenti",
|
||||
"entryLabel": "Eliminati di recente ({count})",
|
||||
"loadError": "Impossibile caricare Eliminati di recente.",
|
||||
"emptyTitle": "Niente in Eliminati di recente",
|
||||
"emptyDescription": "Ciò che elimini resta qui per {days} giorni prima di essere rimosso definitivamente.",
|
||||
"count": "Documenti eliminati ({count})",
|
||||
"restoreAll": "Ripristina tutto",
|
||||
"restore": "Ripristina",
|
||||
"purge": "Elimina ora",
|
||||
"deletedOn": "Eliminato il {date}.",
|
||||
"removesIn": "Rimosso definitivamente tra {days} giorni.",
|
||||
"removesSoon": "Rimosso definitivamente entro un giorno.",
|
||||
"restored": "Documento ripristinato",
|
||||
"openRestored": "Apri",
|
||||
"restoreDuplicate": "Un documento identico è già salvato, quindi questa copia non può essere ripristinata.",
|
||||
"restorePartial": "{failed} su {total} non sono stati ripristinati",
|
||||
"purged": "Documento eliminato definitivamente",
|
||||
"purgeFailed": "Impossibile eliminare il documento.",
|
||||
"purgeConfirmTitle": "Eliminare definitivamente questo documento?",
|
||||
"purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12677,7 +12863,8 @@
|
||||
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia.",
|
||||
"methodSso": "{name} accede con il single sign-on quando usa il link.",
|
||||
"methodBoth": "{name} può impostare una password oppure accedere con il single sign-on."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo: misurazioni, farmaci, documenti e note. Il profilo viene bloccato subito e cancellato dopo {days} giorni. Fino ad allora un amministratore di questo server può annullare l’eliminazione."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}"
|
||||
@@ -12732,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} ha ripristinato una voce della rubrica",
|
||||
"illnessDayLogUpsert": "{name} ha registrato una giornata di malattia",
|
||||
"medicationUpdate": "{name} ha modificato un farmaco",
|
||||
"medicationCourseEnd": "{name} ha concluso un ciclo di terapia",
|
||||
"medicationDelete": "{name} ha eliminato un farmaco",
|
||||
"medicationGlp1Update": "{name} ha modificato una terapia GLP-1",
|
||||
"medicationScheduleCreate": "{name} ha aggiunto uno schema di assunzione",
|
||||
@@ -12779,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato",
|
||||
"rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato",
|
||||
"rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato",
|
||||
"rejectedTrackedValueTypeCreate": "{name} ha provato ad aggiungere un tipo di valore personalizzato ed è stato rifiutato",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} ha provato a modificare un tipo di valore personalizzato ed è stato rifiutato",
|
||||
"rejectedMedicationLayout": "{name} ha provato a riordinare l'elenco dei farmaci ed è stato rifiutato",
|
||||
"deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda",
|
||||
"anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi",
|
||||
"customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato",
|
||||
@@ -13088,7 +13279,8 @@
|
||||
"backgroundScan": "Lettura in background. Con un modello lento può richiedere qualche minuto.",
|
||||
"waitingForWorker": "In attesa del processo in background. Se non cambia, il worker del server potrebbe non essere attivo.",
|
||||
"errorWorkerUnavailable": "Il processo in background non è disponibile al momento. Riprova tra poco o chiedi a chi gestisce il server di controllarlo.",
|
||||
"errorTimedOut": "La lettura ha richiesto più tempo di quanto consenta il tuo tempo di risposta dell'IA. Riprova o aumenta il tempo di risposta nelle impostazioni IA."
|
||||
"errorTimedOut": "La lettura ha richiesto più tempo di quanto consenta il tuo tempo di risposta dell'IA. Riprova o aumenta il tempo di risposta nelle impostazioni IA.",
|
||||
"errorReaderBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nuovo sintomo",
|
||||
|
||||
+339
-80
@@ -175,6 +175,9 @@
|
||||
"failed": "라벨을 읽지 못했어요. 더 선명한 사진으로 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음",
|
||||
"window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음",
|
||||
"windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {total}개 결과 중 {count}개는 과학적으로 검증되지 않은 번역으로 응답했습니다. 참고용으로만 보십시오."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "문제가 생겼어요",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "유효하지 않은 초대예요",
|
||||
"description": "이 초대 링크가 불완전하거나 잘못 복사되었어요. 초대한 분께 새 링크를 요청해 주세요.",
|
||||
"toLogin": "로그인으로 이동"
|
||||
}
|
||||
},
|
||||
"deletionPending": "이 계정은 {date}에 삭제될 예정이며 그때까지 잠겨 있어요. 그 전에는 이 서버의 관리자가 삭제를 취소할 수 있어요.",
|
||||
"deletionPendingNoDate": "이 계정은 삭제 예정이라 잠겨 있어요. 삭제가 끝나기 전까지는 이 서버의 관리자가 취소할 수 있어요."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "암호화 키를 백업하세요",
|
||||
"body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.",
|
||||
"action": "단계 열기"
|
||||
},
|
||||
"quickAddSymptom": "증상 기록",
|
||||
"todayCardVisible": "상단 카드 표시"
|
||||
"todayCardVisible": "상단 카드 표시",
|
||||
"recoveryKitBanner": {
|
||||
"title": "복구 키트를 보관하세요",
|
||||
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
|
||||
"action": "키트 열기"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "측정",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.",
|
||||
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.",
|
||||
"errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.",
|
||||
"errorReasoningOnly": "모델이 생각만 하고 답변을 쓰지 않았어요. 다시 시도하거나 AI 설정에서 다른 모델을 선택해 주세요.",
|
||||
"errorDemo": "데모에서는 코치가 답하지 않아요.",
|
||||
"errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.",
|
||||
"errorNoProviderAction": "AI 제공자 연결",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "암호문구 (12자 이상)",
|
||||
"passphraseHelp": "Argon2id로 보관 파일 키를 만들고 AES-256-GCM으로 봉인해요. 파일은 .hlx 확장자로 저장해 주세요.",
|
||||
"noRecoveryWarning": "복구 수단이 없어요. 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.",
|
||||
"detail": "주간 자동 백업과 같은 형식이에요. 아직 담기지 않는 기록 유형이 있고, 파일의 매니페스트에 무엇이 들었는지 적혀 있어요.",
|
||||
"detail": "보관하고 읽기 위한 기록의 휴대용 사본이에요. 값은 일반 JSON으로, 문서는 메타데이터만 들어 있어요. 이 서버가 계정을 되돌리려고 보관하는 주간 계정 사본이 아니며, 다른 서버에서 계정을 다시 만들 수 없어요. 아직 포함되지 않는 기록 유형도 있어요. 파일의 매니페스트에 들어 있는 항목이 나와요.",
|
||||
"contents": "측정, 복약, 기분, 생리 주기, 검사, 아픈 날 에피소드, 알레르기, 가족력, 영양, 운동, 건강 프로필, 사용자 지표를 JSON 파일 하나에."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "생리 주기 {count}일치를 쓰지 못했어요: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "JSON 파일",
|
||||
"title": "측정값과 기분 (JSON)",
|
||||
"description": "측정과 기분 기록이 담긴 JSON을 붙여 넣거나 .json 파일을 올려 주세요.",
|
||||
"pasteLabel": "JSON 붙여 넣기",
|
||||
"fileInputLabel": "불러올 JSON 파일 선택",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "파일을 읽지 못했어요.",
|
||||
"resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.",
|
||||
"resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요",
|
||||
"droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}",
|
||||
"keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}",
|
||||
"resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.",
|
||||
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요."
|
||||
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요.",
|
||||
"limitsHint": "최대 {mb}MB, 목록마다 최대 {rows}개 항목까지 가능해요. 기록에 항목을 추가할 뿐이며, 전체 백업을 복원하지는 않아요."
|
||||
},
|
||||
"csv": {
|
||||
"title": "CSV 파일",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "데이터를 내보내거나 백업에서 불러와요."
|
||||
"subtitle": "데이터를 내보내거나, 다른 앱과 파일에서 측정값과 기록을 가져와요."
|
||||
},
|
||||
"about": {
|
||||
"title": "앱 정보",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "토큰은 한 번만 표시돼요.",
|
||||
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.",
|
||||
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
|
||||
"namePlaceholder": "이름 (예: Home Assistant)"
|
||||
"namePlaceholder": "이름 (예: Home Assistant)",
|
||||
"appleHealth": {
|
||||
"label": "측정값을 Apple Health에 기록",
|
||||
"createHint": "그러면 iPhone의 HealthLog 앱이 이 토큰의 측정값을 Apple Health에 추가해요. 나중에 아래 토큰 목록에서 바꿀 수 있어요.",
|
||||
"toggleAria": "{name}의 측정값을 Apple Health에 기록",
|
||||
"updateFailed": "Apple Health 설정을 바꾸지 못했어요. 다시 시도해 주세요.",
|
||||
"unattributed": "이 설정이 생기기 전에 보낸 측정값은 토큰과 연결할 수 없어서 Apple Health에 기록되지 않아요."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "운동 토큰",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "패스키, 감사 로그, 세션까지 함께 계정을 삭제해요.",
|
||||
"deleteAccountCta": "계정 삭제",
|
||||
"deleteAccountConfirmTitle": "계정을 영구히 삭제할까요?",
|
||||
"deleteAccountConfirmDescription": "계정과 모든 건강 데이터, 패스키, 세션, 감사 기록이 영구히 삭제돼요. 삭제 직후 로그아웃돼요.",
|
||||
"deleteAccountConfirmTitleGrace": "계정을 삭제할까요?",
|
||||
"deleteAccountConfirmDescription": "계정과 모든 건강 데이터, 패스키, 세션, 감사 기록이 영구히 삭제돼요. 삭제 직후 로그아웃돼요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
|
||||
"deleteAccountSuccess": "계정을 삭제했어요. 로그아웃할게요.",
|
||||
"deleteAccountFailed": "계정을 삭제하지 못했어요",
|
||||
"deleteAccountFinal": "영구 삭제",
|
||||
"deleteAccountFinalGrace": "계정 삭제",
|
||||
"saved": "저장됨",
|
||||
"savingError": "저장 중 오류",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "코치 대화: {days}일 보관",
|
||||
"audit": "보안 활동 기록: {days}일 보관",
|
||||
"delivery": "알림 발송 기록: {days}일 보관",
|
||||
"unavailable": "지금은 보관 기간 정보를 볼 수 없어요."
|
||||
"unavailable": "지금은 보관 기간 정보를 볼 수 없어요.",
|
||||
"accountDeletion": "계정, 관리 프로필 또는 모든 데이터 삭제: {days}일 뒤에 완료",
|
||||
"accountDeletionImmediate": "계정, 관리 프로필 또는 모든 데이터 삭제: 즉시 완료"
|
||||
},
|
||||
"delete": {
|
||||
"title": "삭제 또는 초기화",
|
||||
"description": "계정을 완전히 없애거나, 계정은 두고 건강 기록만 지워요.",
|
||||
"backupLag": "삭제하면 데이터는 즉시 지워져요. 이 서버가 암호화된 외부 백업을 보관한다면 그 저장소에 있는 데이터 사본도 곧바로 삭제되고, 저장소에 연결할 수 없으면 다음 야간 실행 때 삭제돼요.",
|
||||
"openAdvanced": "삭제 또는 초기화"
|
||||
"openAdvanced": "삭제 또는 초기화",
|
||||
"immediateNotice": "삭제하면 데이터는 즉시 지워져요. 이 서버가 암호화된 외부 백업을 보관한다면 그 저장소에 있는 데이터 사본도 곧바로 삭제되고, 저장소에 연결할 수 없으면 다음 야간 실행 때 삭제돼요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
|
||||
"graceNotice": "계정을 삭제하면 바로 잠기고 {days}일 뒤에 이 서버가 보관하는 모든 사본, 서버 밖에 암호화해 둔 사본까지 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 모든 데이터 삭제도 같은 방식이에요. 기록의 사본이 {days}일 동안 보관되어 관리자가 복원할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요."
|
||||
},
|
||||
"posture": {
|
||||
"title": "개인정보 보호 수준",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "꺼짐",
|
||||
"change": "모듈에서 변경"
|
||||
},
|
||||
"timezoneUseDevice": "이 기기의 시간대 사용 ({zone})"
|
||||
"timezoneUseDevice": "이 기기의 시간대 사용 ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "기록에 있는 모든 것이 삭제돼요: 측정값, 약, 기분 기록, 검사 결과, 주기 기록, 문서, AI 대화, 모든 연동 연결과 여기서 파생된 데이터, 앞서 삭제한 데이터까지 전부요. 의사에게 준 공유 링크도 바로 작동을 멈춰요. 계정은 그대로 남고, 로그인에 쓰는 자격 증명과 언어·시간대·단위 같은 표시 설정도 유지돼요. 기록의 사본은 {days}일 동안 보관되므로, 그때까지는 이 서버의 관리자가 복원할 수 있어요. 그 후에는 삭제가 최종적으로 확정돼요.",
|
||||
"deleteAccountCardDetailGrace": "계정은 바로 잠기고 {days}일 뒤에 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요.",
|
||||
"deleteAccountConfirmDescriptionGrace": "바로 로그아웃되고 계정이 잠겨요. {days}일 뒤에는 모든 건강 데이터, 패스키, 세션, 감사 기록과 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
|
||||
"deleteAccountScheduled": "계정이 잠겼고 {date}에 완전히 지워져요. 로그아웃하는 중이에요.",
|
||||
"dangerZoneSuccessGrace": "건강 기록이 삭제됐어요. {date}까지는 이 서버의 관리자가 복원할 수 있어요.",
|
||||
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요."
|
||||
},
|
||||
"admin": {
|
||||
"title": "관리자",
|
||||
@@ -8044,7 +8072,15 @@
|
||||
"cannotLogoutSelf": "자기 자신은 강제 로그아웃할 수 없어요",
|
||||
"emptyTitle": "이 화면에 사용자가 없어요",
|
||||
"emptyDescription": "현재 필터에 맞는 사용자가 없어요.",
|
||||
"emptyResetFilter": "전체 사용자 보기"
|
||||
"emptyResetFilter": "전체 사용자 보기",
|
||||
"deletionPending": "{date} 삭제 예정",
|
||||
"deletionPendingNoDate": "삭제 예정",
|
||||
"undoDeletion": "삭제 취소",
|
||||
"undoDeletionTitle": "{name} 삭제를 취소할까요?",
|
||||
"undoDeletionBody": "{name} 님이 이 계정의 삭제를 요청했고, 유예 기간이 끝나면 지워져요. 취소하면 계정과 그 안의 모든 내용이 그대로 남아요. {name} 님은 다시 로그인할 수 있지만, API 토큰, 공유 링크, 신뢰하는 기기는 계속 취소된 상태라 다시 설정해야 해요.",
|
||||
"undoDeletionBodyGuardian": "보호자가 관리 프로필 {name}을(를) 삭제했고, 유예 기간이 끝나면 지워져요. 취소하면 프로필과 그 안의 모든 내용이 그대로 남고, 보호자들이 다시 접근할 수 있어요. 공유 링크와 토큰은 계속 취소된 상태라 다시 설정해야 해요.",
|
||||
"undoDeletionDone": "{name} 삭제를 취소했어요",
|
||||
"undoDeletionFailed": "삭제를 취소하지 못했어요"
|
||||
},
|
||||
"invites": {
|
||||
"title": "초대",
|
||||
@@ -8106,8 +8142,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
"subtitle": "PostgreSQL에 저장되는 주간 사용자별 데이터 스냅샷이에요.",
|
||||
"description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이에요. 내려받기는 요청한 관리자에게만 스냅샷을 복호화해 주고, 암호문은 데이터베이스에 그대로 남아요.",
|
||||
"subtitle": "이 서버의 데이터베이스에 보관된 계정 사본으로, 이 서버에서 계정 하나를 되돌릴 때 써요.",
|
||||
"description": "각 계정에는 매주 일요일마다 교체되는 주간 사본 하나와, 여기서 올린 사본, \"모든 데이터 삭제\" 전에 만든 사본이 있어요. 사본은 이 서버에 있는 계정 하나를 되돌려요. 잃어버린 서버를 다시 만들 수는 없어요. 그러려면 데이터베이스 덤프와 암호화 키가 필요해요. 크기는 암호화된 사본 기준이에요. 다운로드하면 요청한 관리자를 위해 복호화되고, 암호문은 데이터베이스에 남아요.",
|
||||
"runNow": "지금 백업",
|
||||
"runEnqueued": "백업 작업을 큐에 넣었어요",
|
||||
"runFailed": "백업을 큐에 넣지 못했어요",
|
||||
@@ -8117,12 +8153,11 @@
|
||||
"emptyDescription": "백업은 매주 일요일 03:00에 자동으로 돌아요. 지금 실행해서 첫 스냅샷을 만들 수도 있어요.",
|
||||
"colUser": "사용자",
|
||||
"colType": "유형",
|
||||
"typeWeeklyAuto": "주간 (자동)",
|
||||
"typeWeeklyAuto": "주간 계정 사본",
|
||||
"typeManual": "수동",
|
||||
"colSize": "크기",
|
||||
"colCreatedAt": "최근 스냅샷",
|
||||
"colActions": "동작",
|
||||
"retentionLabel": "보관 기간: {days}일",
|
||||
"scheduleStaleTitle": "주간 백업이 중단되었습니다",
|
||||
"scheduleStaleDescription": "가장 최근의 예약 사본이 {days}일 전 것입니다. 정상적으로 도는 주간 실행은 {threshold}일보다 오래된 사본을 남기지 않습니다.",
|
||||
"scheduleFailedTitle": "마지막 예약 백업 실행이 실패했습니다",
|
||||
@@ -8140,7 +8175,7 @@
|
||||
"downloadFailed": "다운로드하지 못했어요",
|
||||
"downloadStarted": "다운로드를 시작했어요",
|
||||
"uploadTitle": "백업 업로드",
|
||||
"uploadDescription": "백업 파일을 검사한 뒤 수동 스냅샷으로 저장해요. 복원은 별도 단계예요.",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.",
|
||||
"uploadButton": "파일 업로드",
|
||||
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
|
||||
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
|
||||
@@ -8200,7 +8235,7 @@
|
||||
"restoreSkippedAccountSetting": "계정 설정",
|
||||
"restoreMissingTitle": "복원을 중단했어요. 파일이 불완전해요",
|
||||
"restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.",
|
||||
"restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.",
|
||||
"restoreMissingRemedy": "이 서버의 계정 사본이나 서버 밖 사본을 쓰거나, 계정을 다시 내보내 새 파일을 올려요. 매니페스트에서 생략됐다고 밝힌 섹션은 다른 경우이며 정상적으로 복원돼요.",
|
||||
"restoreMissingDocuments": "문서",
|
||||
"restoreMissingWorkouts": "운동",
|
||||
"restoreMissingMentalHealth": "정신 건강 선별 검사",
|
||||
@@ -8230,6 +8265,9 @@
|
||||
"restoreActive": "이 계정의 복원이 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
|
||||
"restoreJobQueuedTitle": "{username} 복원이 시작을 기다리고 있어요",
|
||||
"restoreJobQueuedNote": "백그라운드 작업자가 가져가는 즉시 시작돼요.",
|
||||
"restoreJobWaitingBackupNote": "서버에서 실행 중인 백업을 기다리고 있어요. 복원은 백업과 동시에 실행되지 않으며, 백업이 끝나고 1분쯤 뒤에 시작돼요.",
|
||||
"restoreJobWaitingBusyNote": "가져오기나 야간 작업 같은 서버의 큰 작업이 끝나기를 기다리고 있어요. 그 후 1분쯤 뒤에 시작돼요.",
|
||||
"restoreJobQueuedSince": "{when}부터 대기 중이에요.",
|
||||
"restoreJobRunningTitle": "{username} 복원 중",
|
||||
"restoreJobRunningNote": "복원이 끝날 때까지 계정에는 기존 데이터가 그대로 보여요. 이 페이지를 떠나도 복원은 서버에서 계속돼요.",
|
||||
"restoreJobResumed": "서버가 다시 시작된 뒤 복원을 다시 시작했어요.",
|
||||
@@ -8250,7 +8288,7 @@
|
||||
"restoreFailureOwnerMismatch": "백업 안의 파일이 백업 행과 다른 계정의 것이에요.",
|
||||
"restoreFailureOwnerMissing": "이 백업이 속한 계정이 더 이상 없어요.",
|
||||
"restoreFailureSectionMissing": "파일이 불완전해요. 매니페스트에 적힌 항목이 파일에 없어요.",
|
||||
"restoreFailureDocument": "휴대용 내보내기처럼 내용 없이 문서만 담긴 파일이에요. 재해 복구용 백업을 사용해 주세요.",
|
||||
"restoreFailureDocument": "이 파일에는 휴대용 내보내기처럼 내용 없이 문서만 들어 있어요. 대신 이 서버의 계정 사본이나 서버 밖 사본을 써요.",
|
||||
"restoreFailureTimeBudget": "이 백업은 너무 커서 백그라운드 작업의 시간 제한 안에 복원할 수 없어요.",
|
||||
"restoreFailureTransaction": "데이터베이스가 복원 도중에 거부해서 모든 변경을 되돌렸어요. 서버 로그에 오류가 나와 있어요.",
|
||||
"restoreFailureCauseTimeout": "복원이 데이터베이스가 허용하는 시간보다 오래 걸려서 모든 변경을 되돌렸어요. 데이터베이스 호스트가 바빴다면 한가할 때 다시 시작하세요. 서버 로그에 해당 단계가 나와 있어요.",
|
||||
@@ -8271,7 +8309,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)",
|
||||
"restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)",
|
||||
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)",
|
||||
"typePreWipe": "모든 데이터 삭제 전",
|
||||
"retentionNote": "주간 사본은 매주 일요일에 교체돼요. \"모든 데이터 삭제\" 전에 만든 사본은 {days}일 뒤에 삭제돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요.",
|
||||
"retentionNoteNoGrace": "주간 사본은 매주 일요일에 교체돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요.",
|
||||
"database": {
|
||||
"title": "데이터베이스 덤프",
|
||||
"lastGood": "마지막 정상 덤프 {when}, {size}, {where}.",
|
||||
"whereBoth": "이 서버와 외부 저장소에 보관됨",
|
||||
"whereLocal": "이 서버에 보관됨",
|
||||
"whereOffhost": "외부 저장소에만 있음",
|
||||
"none": "아직 데이터베이스 덤프가 없습니다. 첫 덤프는 오늘 밤에 실행됩니다.",
|
||||
"failedTitle": "마지막 데이터베이스 덤프가 실패했습니다",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "데이터베이스 덤프가 꺼져 있습니다(DB_BACKUP_ENABLED=false). 다른 방법으로 데이터베이스를 백업하세요.",
|
||||
"noDirectoryOffhost": "이 서버는 {dir}에 쓸 수 없어 덤프가 외부 저장소로만 가고 여기에는 보관되지 않습니다. 백업 볼륨을 마운트하세요.",
|
||||
"noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요.",
|
||||
"retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 야간 덤프 {nights}개가 보관되고, 마지막 업데이트 전에 만든 덤프는 다음 업데이트까지 남습니다.",
|
||||
"retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.",
|
||||
"retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.",
|
||||
"notPersistentOffhost": "{dir}은(는) 영구 저장소에 있지 않습니다(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음). 그래서 여기에 쓴 덤프는 다음 업데이트 때 사라집니다. 덤프는 외부 저장소로만 가며, 여기에는 보관되지 않고 마이그레이션 전에도 만들어지지 않습니다. 백업 볼륨을 마운트하세요.",
|
||||
"notPersistent": "{dir}은(는) 영구 저장소에 있지 않고(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음) 외부 버킷도 설정되지 않아, 데이터베이스 덤프가 어디에도 보관되지 않습니다. 백업 볼륨을 마운트하세요.",
|
||||
"offhostFailedTitle": "최신 데이터베이스 덤프가 외부 버킷에 도달하지 못했습니다",
|
||||
"offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.",
|
||||
"offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다."
|
||||
},
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
@@ -8337,7 +8400,10 @@
|
||||
"columnsHide": "표 숨기기",
|
||||
"columnsShowAll": "열 {count}개 모두 보기",
|
||||
"columnsShowWithRows": "행이 있는 열만",
|
||||
"columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요."
|
||||
"columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요.",
|
||||
"backupsDumpLocalLine": "키 {key}: 이 서버의 데이터베이스 덤프 {count}개가 이 키로 작성되었습니다. 보존 정책이 덤프를 지울 때까지 필요합니다.",
|
||||
"backupsDumpOffhostLine": "키 {key}: 이 키로 작성된 최신 데이터베이스 덤프가 {when}에 버킷에 저장되었습니다. {until}까지 필요합니다",
|
||||
"backupsDumpOffhostLineUnknown": "키 {key}: 이 키로 작성된 최신 데이터베이스 덤프가 {when}에 버킷에 저장되었습니다. 버킷이 database/를 얼마나 보관하는지 알 수 없으므로 그 덤프가 사라진 것을 확인할 때까지 키를 유지하세요."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "측정값 테이블 유지 관리",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "페이지의 이 부분을 표시할 수 없습니다",
|
||||
"description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "암호화 키를 백업하세요",
|
||||
"description": "이 키의 사본이 없으면 암호화된 데이터와 이 서버의 백업을 다시는 읽을 수 없습니다.",
|
||||
"loading": "키 백업 상태를 불러오는 중…",
|
||||
"loadError": "키 백업 상태를 불러오지 못했습니다.",
|
||||
"badgeDue": "해야 함",
|
||||
"badgeDone": "백업됨",
|
||||
"keyId": "활성 키 ID",
|
||||
"fingerprint": "지문",
|
||||
"whatIsLost": "키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 서버가 다른 키로 다시 설치되면 HealthLog는 원래 키가 돌아올 때까지 서비스를 시작하지 않습니다.",
|
||||
"fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.",
|
||||
"checkLabel": "내 사본 확인",
|
||||
"checkButton": "확인",
|
||||
"checkNote": "붙여넣은 키는 활성 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.",
|
||||
"checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.",
|
||||
"checkMatches": "사본이 활성 키와 일치합니다.",
|
||||
"checkNoMatch": "사본이 활성 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.",
|
||||
"checkFailed": "확인하지 못했습니다.",
|
||||
"confirmButton": "이 키를 백업했습니다",
|
||||
"confirmed": "키 백업이 확인되었습니다.",
|
||||
"confirmFailed": "확인을 저장하지 못했습니다.",
|
||||
"stale": "이 페이지를 연 뒤 서버의 키가 바뀌었습니다. 새 키 ID와 지문을 확인한 뒤 다시 확인하세요.",
|
||||
"confirmedAt": "{date}에 확인됨.",
|
||||
"confirmedAtBy": "{date}에 {who}님이 확인함.",
|
||||
"dueAgain": "키 {keyId}의 백업이 확인되었지만 서버는 이제 다른 키를 사용합니다. 현재 키를 백업하세요.",
|
||||
"tileLabel": "암호화 키 백업",
|
||||
"tileDue": "확인 안 됨",
|
||||
"tileDone": "확인됨",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누른 뒤 환경 설정에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY를 복사하세요."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog가 데이터를 열 수 없습니다",
|
||||
"body": "이 서버를 시작한 암호화 키가 데이터베이스를 기록할 때 쓴 키와 다릅니다. 데이터를 보호하기 위해 이 문제가 해결될 때까지 HealthLog는 아무것도 제공하지 않습니다.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.",
|
||||
"gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)",
|
||||
"gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "복구 키트",
|
||||
"description": "이 서버를 복원하는 데 필요한 모든 키를 ID와 지문으로 보여 줍니다.",
|
||||
"intro": "아래 각 키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 키가 없으면 데이터베이스 덤프도 백업 사본도 다시 읽을 수 없으며, 필요한 키가 없으면 HealthLog는 시작하지 않습니다.",
|
||||
"loading": "복구 키트를 불러오는 중…",
|
||||
"loadError": "복구 키트를 불러오지 못했습니다.",
|
||||
"badgeDue": "해야 함",
|
||||
"badgeDone": "보관됨",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "지문",
|
||||
"noFingerprint": "이 서버에 없음",
|
||||
"kind": {
|
||||
"application": "애플리케이션 키 {id}",
|
||||
"backup": "외부 보관 백업 키",
|
||||
"backupPrevious": "이전 외부 보관 백업 키",
|
||||
"token": "토큰 키"
|
||||
},
|
||||
"need": {
|
||||
"active": "새 데이터를 암호화합니다.",
|
||||
"configured": "이 서버에 있습니다.",
|
||||
"storedCopies": "저장된 사본 {count}개에 필요합니다.",
|
||||
"offhostUntil": "{date}까지 외부 보관 사본에 필요합니다.",
|
||||
"offhostUnknown": "버킷이 외부 보관 사본을 보관하는 동안 필요합니다.",
|
||||
"backupActive": "새 외부 보관 사본을 모두 봉인합니다.",
|
||||
"backupPrevious": "마지막 키 변경 전에 기록된 외부 보관 사본을 엽니다.",
|
||||
"token": "세션, 토큰, 공유 링크에 서명합니다. 이 키가 없으면 모두 로그아웃되지만 데이터는 잃지 않습니다.",
|
||||
"localDumps": "이 서버의 데이터베이스 덤프 {count}개에 필요합니다.",
|
||||
"offhostDumpsUntil": "{date}까지 외부 보관 데이터베이스 덤프에 필요합니다.",
|
||||
"offhostDumpsUnknown": "버킷이 외부 보관 데이터베이스 덤프를 보관하는 동안 필요합니다."
|
||||
},
|
||||
"source": "{variable}에 설정됨.",
|
||||
"status": {
|
||||
"confirmed": "보관됨",
|
||||
"new": "아직 보관 안 됨",
|
||||
"notConfigured": "이 서버에 없음",
|
||||
"unreadable": "유효한 키가 아님"
|
||||
},
|
||||
"notConfiguredHint": "아직 이 키가 필요한 백업이 있지만 이 서버에는 키가 없습니다. 다른 곳에 보관한 사본만 그 백업을 읽을 수 있습니다. 그 사본을 확보한 뒤 키트를 확인하세요.",
|
||||
"unreadableHint": "변수는 설정되어 있지만 유효한 키가 들어 있지 않습니다. 외부 보관 사본에 의존하기 전에 고치세요.",
|
||||
"fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.",
|
||||
"whereTitle": "키가 설정된 위치",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "키는 docker-compose.yml 옆 .env 파일에 있습니다: ENCRYPTION_KEY 또는 ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY, 그리고 외부 보관 사본을 쓰는 경우 BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY입니다.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요."
|
||||
},
|
||||
"restoreTitle": "이 서버 복원하기",
|
||||
"restoreBody": "같은 키로 HealthLog를 설치하고, 가장 최근의 데이터베이스 덤프를 빈 데이터베이스에 복원한 다음 앱을 시작하세요. 가이드에서 단계별로 안내합니다.",
|
||||
"restoreLink": "재해 복구 가이드",
|
||||
"checkLabel": "내 사본 확인",
|
||||
"checkKeyLabel": "확인할 키",
|
||||
"checkValueLabel": "내가 보관한 키 사본",
|
||||
"checkButton": "확인",
|
||||
"checkNote": "붙여넣은 키는 이 서버의 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.",
|
||||
"checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.",
|
||||
"checkMatches": "사본이 이 키와 일치합니다.",
|
||||
"checkNoMatch": "사본이 이 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.",
|
||||
"checkFailed": "확인하지 못했습니다.",
|
||||
"confirmButton": "모든 키의 사본을 보관했습니다",
|
||||
"confirmed": "복구 키트가 확인되었습니다.",
|
||||
"confirmFailed": "확인을 저장하지 못했습니다.",
|
||||
"stale": "이 페이지를 연 뒤 키트가 바뀌었습니다. 지금 표시된 키를 확인한 뒤 다시 확인하세요.",
|
||||
"confirmedAt": "{date}에 확인됨.",
|
||||
"confirmedAtBy": "{date}에 {who}님이 확인함.",
|
||||
"dueAgain": "키트는 {date}에 확인되었습니다. 이제 {keys}도 포함되며, 그 확인은 이를 다루지 않았습니다. 각각의 사본을 저장하고 다시 확인하십시오."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "복구 준비 상태",
|
||||
"description": "이 서버가 만드는 모든 백업을 기준으로, 서버를 잃었을 때 되살릴 수 있는지 판단합니다.",
|
||||
"loading": "백업을 확인하는 중…",
|
||||
"loadError": "복구 준비 상태를 불러오지 못했습니다.",
|
||||
"state": {
|
||||
"ok": "준비됨",
|
||||
"attention": "확인 필요",
|
||||
"failing": "위험"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "데이터베이스 덤프",
|
||||
"weekly_copy": "주간 계정 사본",
|
||||
"offhost_copies": "외부 보관 사본",
|
||||
"restore_drill": "복원 훈련",
|
||||
"offhost_setup": "외부 보관 백업",
|
||||
"recovery_kit": "복구 키트",
|
||||
"dump_retention": "버킷의 덤프"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "최신 덤프: {date}",
|
||||
"weekly_copy": "가장 오래된 계정 사본: {date}",
|
||||
"offhost_copies": "가장 오래된 외부 보관 사본: {date}",
|
||||
"restore_drill": "마지막 훈련: {date}",
|
||||
"recovery_kit": "확인: {date}"
|
||||
},
|
||||
"dueBy": "{date}까지",
|
||||
"fix": {
|
||||
"database_dump": "백업 열기",
|
||||
"weekly_copy": "백업 열기",
|
||||
"offhost_copies": "백업 열기",
|
||||
"restore_drill": "백업 열기",
|
||||
"offhost_setup": "백업 열기",
|
||||
"recovery_kit": "키트 열기",
|
||||
"dump_retention": "백업 열기"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "이 버전은 데이터베이스 덤프를 예약하지 않습니다.",
|
||||
"disabled": "예약된 데이터베이스 덤프가 꺼져 있어 이 서버를 다시 구축할 수 있는 사본이 없습니다. 다시 켜려면 DB_BACKUP_ENABLED를 제거하거나 true로 설정한 뒤 HealthLog를 다시 시작하십시오.",
|
||||
"unreadable": "데이터베이스 덤프 상태를 읽지 못했습니다.",
|
||||
"never_succeeded": "아직 성공한 데이터베이스 덤프가 없으며 마지막 시도도 실패했습니다.",
|
||||
"never_ran": "덤프를 켠 뒤 {hours}시간 동안 데이터베이스 덤프가 기록되지 않았습니다.",
|
||||
"first_pending": "첫 데이터베이스 덤프가 아직 실행되지 않았습니다.",
|
||||
"stale": "최신 데이터베이스 덤프가 {hours}시간 전 것입니다.",
|
||||
"last_failed": "마지막 데이터베이스 덤프가 실패했습니다. 그 전 덤프는 아직 최근 것입니다.",
|
||||
"fresh": "최신 데이터베이스 덤프가 최근 것입니다.",
|
||||
"nowhere_to_write": "덤프 디렉터리 {directory}에 쓸 수 없고 외부 버킷도 설정되어 있지 않아 덤프를 보관할 수 없습니다.",
|
||||
"not_persistent": "덤프 디렉터리 {directory}이(가) 영구 저장소에 있지 않고 외부 버킷도 설정되지 않아, 모든 덤프가 다음 업데이트 때 사라집니다. 백업 볼륨을 마운트하세요.",
|
||||
"fresh_offhost_only": "최신 데이터베이스 덤프는 최근 것이며 외부 버킷에 있습니다. {directory}이(가) 영구 저장소에 있지 않아 이 서버에는 보관되지 않으며, 마이그레이션 전에도 만들어지지 않습니다.",
|
||||
"offhost_failed": "최신 데이터베이스 덤프는 이 서버에 보관되어 있지만 외부 버킷으로의 업로드가 실패했습니다.",
|
||||
"offhost_stale": "{hours}시간 동안 외부 버킷에 도달한 데이터베이스 덤프가 없습니다. 업로드가 계속 실패하고 있습니다. 이 서버를 잃으면 복구에 쓸 최근 덤프가 없습니다.",
|
||||
"offhost_never": "외부 버킷에 도달한 데이터베이스 덤프가 한 번도 없습니다. 업로드가 실패합니다. 이 서버를 잃으면 복구에 쓸 덤프가 없습니다."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "아직 복사할 계정이 없습니다.",
|
||||
"stale": "가장 오래된 주간 사본이 {days}일 전 것입니다. 뒤처진 계정: {accounts}.",
|
||||
"never": "{days}일 동안 주간 사본이 없는 계정이 있습니다. 뒤처진 계정: {accounts}.",
|
||||
"first_pending": "첫 주간 사본은 일요일 이른 시간에 만들어집니다.",
|
||||
"fresh": "모든 계정에 지난주 사본이 있습니다."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "외부 보관 버킷이 설정되지 않았습니다.",
|
||||
"stale": "외부 보관 사본이 이틀 밤 넘게 뒤처져 있습니다. 뒤처진 계정: {accounts}.",
|
||||
"first_pending": "야간 외부 보관 실행이 아직 어떤 계정에도 도달하지 않았습니다.",
|
||||
"fresh": "모든 계정의 최근 사본이 외부 보관 버킷에 있습니다."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "훈련은 외부 보관 버킷을 읽는데, 버킷이 설정되지 않았습니다.",
|
||||
"not_run_yet": "월간 복원 훈련이 아직 실행되지 않았습니다.",
|
||||
"failed": "마지막 복원 훈련이 실패했습니다: 사본 {checked}개 중 {failed}개를 다시 읽지 못했습니다.",
|
||||
"failed_run": "마지막 복원 훈련을 끝까지 마치지 못했습니다.",
|
||||
"passed": "마지막 복원 훈련에서 사본 {checked}개를 다시 읽었습니다.",
|
||||
"nothing_checked": "마지막 복원 훈련이 사본을 하나도 다시 읽기 전에 시간 제한으로 멈췄기 때문에 아무것도 확인하지 못했습니다."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "사본이 매일 밤 이 서버 밖으로 나갑니다.",
|
||||
"no_admin": "아직 관리자 계정이 없습니다.",
|
||||
"not_configured": "외부 보관 버킷이 설정되지 않아 모든 사본이 이 서버에만 있습니다.",
|
||||
"not_configured_grace": "외부 보관 버킷이 아직 설정되지 않아 모든 사본이 이 서버에만 있습니다."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "복원에 필요한 모든 키의 사본이 보관되어 있습니다.",
|
||||
"out_of_date": "복구 키트에 이제 {keys}도 포함되며, 마지막 확인은 이를 다루지 않았습니다. 고장 난 것은 없습니다. 사본을 저장하고 키트를 다시 확인하십시오.",
|
||||
"no_admin": "아직 관리자 계정이 없습니다.",
|
||||
"not_confirmed": "복원에 필요한 키의 사본을 아무도 확인하지 않았습니다: {keys}.",
|
||||
"not_confirmed_grace": "복원에 필요한 키의 사본을 보관하세요: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "외부 버킷이 설정되지 않았습니다.",
|
||||
"no_offhost_dumps": "버킷으로 가는 데이터베이스 덤프가 없습니다.",
|
||||
"missing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.",
|
||||
"configured": "버킷은 {days}일 후에 덤프를 삭제합니다.",
|
||||
"unknown": "버킷이 오래된 덤프를 삭제하는 규칙이 있는지 알려 주지 않습니다. 수명 주기 규칙이 database/를 포함하는지 확인하세요."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "데이터베이스 덤프가 다시 최신 상태입니다.",
|
||||
"weekly_copy": "모든 계정에 다시 최근 주간 사본이 있습니다.",
|
||||
"offhost_copies": "모든 계정에 다시 최근 외부 보관 사본이 있습니다.",
|
||||
"restore_drill": "복원 훈련을 다시 통과했습니다.",
|
||||
"offhost_setup": "이제 외부 보관 사본이 설정되었습니다.",
|
||||
"recovery_kit": "복구 키트가 다시 확인되었습니다.",
|
||||
"dump_retention": "이제 버킷의 수명 주기 규칙이 데이터베이스 덤프를 포함합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "오프라인 위치 데이터베이스가 로드되지 않았어요",
|
||||
"offlineGeoUnavailableBody": "HealthLog가 {host}의 온라인 조회로 로그인 위치를 확인하고 있어서, 모든 로그인 IP 주소가 이 서버를 벗어나요. 로컬에서 확인하려면 무료 MaxMind 계정으로 GeoLite2-City와 GeoLite2-ASN 데이터베이스를 내려받아 그 디렉터리를 컨테이너에 마운트하고 GEOLITE2_DIR가 이를 가리키게 하세요. 절차: {docsUrl}",
|
||||
"tlsPinChangedTitle": "TLS 리프 인증서 변경: {host}",
|
||||
"tlsPinChangedBody": "{host}가 제공하는 TLS 리프의 SPKI 핀이 이제 {servedPin}인데, 알려진 핀 목록에 없어요. 기존 핀이 사라지면 핀을 검증하는 클라이언트(네이티브 앱)는 연결에 실패해요. 새 리프 핀을 다시 추출해 TLS_LEAF_SPKI_PINS와 네이티브 클라이언트 핀 목록에 추가하고, 이 인증서가 만료되는 {validTo} 전에 빌드를 배포하세요. docs/ops/tls-cert-pin.md를 참고하세요."
|
||||
"tlsPinChangedBody": "{host}가 제공하는 TLS 리프의 SPKI 핀이 이제 {servedPin}인데, 알려진 핀 목록에 없어요. 기존 핀이 사라지면 핀을 검증하는 클라이언트(네이티브 앱)는 연결에 실패해요. 새 리프 핀을 다시 추출해 TLS_LEAF_SPKI_PINS와 네이티브 클라이언트 핀 목록에 추가하고, 이 인증서가 만료되는 {validTo} 전에 빌드를 배포하세요. docs/ops/tls-cert-pin.md를 참고하세요.",
|
||||
"accountErasureRefusedTitle": "계정을 지우지 못했어요: {username}",
|
||||
"accountErasureRefusedBody": "{username} 계정의 삭제 유예 기간이 끝났지만, 이 계정이 관리 프로필의 마지막 활성 보호자라서 지우지 않았어요. 계정은 계속 잠겨 있어요. 프로필에 다른 보호자를 추가하거나, 프로필을 삭제하거나, 관리자의 사용자 화면에서 삭제를 취소해 주세요.",
|
||||
"accountErasureFailedBody": "{username} 계정의 삭제 유예 기간이 끝났지만 계정을 지우지 못했어요. 계정은 계속 잠겨 있고, 삭제할 다른 계정들 다음으로 매시간 다시 시도해요. 오류는 관리자 화면의 실패한 작업 카드에서 볼 수 있어요."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "검진 리마인더",
|
||||
"description": "검진 리마인더를 개별로 켜거나 끕니다."
|
||||
},
|
||||
"eventBackupHealth": "백업 상태",
|
||||
"eventBackupHealthDesc": "백업이 멈추거나 뒤처지거나 다시 정상으로 돌아오면 보내요. 관리자에게만 보내요.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "백업에 확인이 필요해요",
|
||||
"titleFailing": "백업이 위험해요",
|
||||
"titleReminder": "백업이 여전히 위험해요",
|
||||
"titleRecovered": "백업이 다시 정상이에요"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11054,7 +11269,6 @@
|
||||
"who5": "웰빙 점수가 50점 이하라면 좀 더 살펴볼 만해요. 원한다면 이 페이지의 PHQ-9 점검이 기분을 더 자세히 살펴봐요. 진단이 아니라 가벼운 제안이에요.",
|
||||
"sci": "이 설문의 바탕이 된 연구에서는 16점 이하가 불면증 가능성과 관련이 있었어요. 수면 문제가 계속 힘들게 한다면 전문가와 이야기해 보는 게 도움이 될 수 있어요. 진단은 아니에요."
|
||||
},
|
||||
"validatedInEnglishNote": "이 설문은 영어로 검증된 도구예요. 문항은 원문 영어 표현 그대로 보여줘요.",
|
||||
"openDetail": "자세히 보기",
|
||||
"historyLoadError": "검사 기록을 불러오지 못했어요",
|
||||
"historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.",
|
||||
@@ -11104,6 +11318,13 @@
|
||||
"belowThreshold": "지난 한 달 동안의 수면이 이 설문의 바탕이 된 연구에서 불면증 가능성이 높다고 본 범위에 있었어요.",
|
||||
"aboveThreshold": "지난 한 달 동안의 수면이 그 연구가 불면증과 연결한 범위 밖에 있었어요."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "이 번역은 과학적으로 검증되지 않았어요. 결과는 참고용으로만 봐 주세요.",
|
||||
"showOriginal": "영어 원문 보기",
|
||||
"showTranslation": "번역 보기",
|
||||
"resultUnvalidated": "과학적으로 검증되지 않은 번역으로 답했어요. 결과는 참고용으로만 봐 주세요.",
|
||||
"historyUnvalidated": "과학적으로 검증되지 않은 번역으로 답함"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11631,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "이미 저장돼 있어요. 기존 문서를 표시해요.",
|
||||
"deleted": "문서를 삭제했어요",
|
||||
"restoreFailed": "문서를 복구하지 못했어요."
|
||||
"restoreFailed": "문서를 복구하지 못했어요.",
|
||||
"deletedHint": "삭제한 문서는 {days}일 동안 최근 삭제됨에 남아 있어요."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "더 불러오는 중…",
|
||||
@@ -11676,7 +11898,8 @@
|
||||
"raster-failed": "PDF 페이지를 읽기용으로 렌더링하지 못했어요. 스캔 팩스 압축 형식이 흔한 원인이에요.",
|
||||
"provider-error": "지난번에 AI 제공자가 이 문서를 읽지 못했어요.",
|
||||
"pdf-needs-anthropic": "설정된 AI 제공자는 PDF 파일을 읽을 수 없어요. Claude 제공자나 로컬 OCR로는 읽을 수 있어요.",
|
||||
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요."
|
||||
"empty-transcription": "AI 제공자가 이 문서에서 텍스트를 찾지 못했어요.",
|
||||
"resource-limit": "이 PDF는 너무 크거나 복잡해서 여기서 읽을 수 없습니다. 파일 자체는 정상이며 계속 열 수 있습니다."
|
||||
}
|
||||
},
|
||||
"loadError": "이 문서를 불러오지 못했어요.",
|
||||
@@ -11748,7 +11971,7 @@
|
||||
"dismiss": "닫기",
|
||||
"empty": "이 문서에서 읽어낼 내용이 없었어요.",
|
||||
"unavailableBody": "AI 제공자를 설정하면 제목·종류·날짜를 추천해요.",
|
||||
"unavailableLocalOcr": "AI 설정에서 로컬 OCR을 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableLocalOcr": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableAction": "AI 설정 열기",
|
||||
"errorGeneric": "문서를 읽지 못했어요. 다시 시도해 주세요.",
|
||||
"errorOcr": "이 이미지에서 텍스트를 읽지 못했어요.",
|
||||
@@ -11760,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "PDF를 읽으려면 Claude 비전 제공자가 필요해요.",
|
||||
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.",
|
||||
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
|
||||
"errorLocalOcrDisabled": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 먼저 켜 주세요.",
|
||||
"errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.",
|
||||
"errorTextImageOnly": "로컬 OCR은 이미지만 읽어요. 이 파일은 읽을 수 없어요.",
|
||||
"errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.",
|
||||
"errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다."
|
||||
"errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다.",
|
||||
"unavailableLocalOcrAction": "검사 설정 열기"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "요약하기",
|
||||
@@ -11820,6 +12045,7 @@
|
||||
"extracting": "추출 중…",
|
||||
"extractHint": "이 문서에서 이미 읽은 텍스트를 사용해요. 추출한 값은 검토를 기다리며, 자동으로 저장되지 않아요.",
|
||||
"extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.",
|
||||
"extractIncomplete": "문서 일부를 끝까지 읽지 못해서 빠진 값이 있을 수 있어요. 목록을 문서와 비교해 주세요.",
|
||||
"failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.",
|
||||
"failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.",
|
||||
"failGeneric": "이 값을 저장하지 못했어요.",
|
||||
@@ -11867,6 +12093,7 @@
|
||||
"loadMore": "더 불러오기",
|
||||
"stateImported": "HealthLog에 있음",
|
||||
"stateDeleted": "HealthLog에서 삭제됨",
|
||||
"stateInTrash": "최근 삭제됨에 있음",
|
||||
"selectedCount": "최대 {max}개 중 {count}개 선택",
|
||||
"importAction": "{count}개 가져오기",
|
||||
"importing": "문서를 하나씩 가져오는 중…",
|
||||
@@ -11880,7 +12107,9 @@
|
||||
"importedLinked": "가져오고 연결함",
|
||||
"duplicate": "이미 HealthLog에 있음",
|
||||
"duplicateLinked": "이미 HealthLog에 있음, 이제 연결됨",
|
||||
"deleted": "HealthLog에서 삭제됨, 그대로 둠"
|
||||
"deleted": "HealthLog에서 삭제됨, 그대로 둠",
|
||||
"restored": "최근 삭제됨에서 복구했어요",
|
||||
"restoredLinked": "최근 삭제됨에서 복구하고 연결했어요"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "이 서버에서는 문서 보관함이 활성화되어 있지 않습니다.",
|
||||
@@ -11924,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "이 문서를 삭제할까요?",
|
||||
"titleMany": "문서 {count}개를 삭제할까요?",
|
||||
"body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.",
|
||||
"body": "삭제한 문서는 {days}일 동안 최근 삭제됨에 남아 있다가 완전히 삭제됩니다.",
|
||||
"action": "삭제"
|
||||
},
|
||||
"trash": {
|
||||
"title": "최근 삭제됨",
|
||||
"subtitle": "삭제한 문서는 {days}일 동안 여기 남아 있다가 완전히 삭제됩니다.",
|
||||
"back": "문서",
|
||||
"entryLabel": "최근 삭제됨 ({count})",
|
||||
"loadError": "최근 삭제됨을 불러오지 못했어요.",
|
||||
"emptyTitle": "최근 삭제됨이 비어 있어요",
|
||||
"emptyDescription": "삭제한 문서는 완전히 삭제되기 전까지 {days}일 동안 여기서 기다려요.",
|
||||
"count": "삭제한 문서 ({count})",
|
||||
"restoreAll": "모두 복구",
|
||||
"restore": "복구",
|
||||
"purge": "지금 삭제",
|
||||
"deletedOn": "{date}에 삭제함.",
|
||||
"removesIn": "{days}일 후 완전히 삭제됩니다.",
|
||||
"removesSoon": "하루 안에 완전히 삭제됩니다.",
|
||||
"restored": "문서를 복구했어요",
|
||||
"openRestored": "열기",
|
||||
"restoreDuplicate": "같은 문서가 이미 저장되어 있어서 이 사본은 복구할 수 없어요.",
|
||||
"restorePartial": "{total}개 중 {failed}개를 복구하지 못했어요",
|
||||
"purged": "문서를 완전히 삭제했어요",
|
||||
"purgeFailed": "문서를 삭제하지 못했어요.",
|
||||
"purgeConfirmTitle": "이 문서를 완전히 삭제할까요?",
|
||||
"purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12610,7 +12863,8 @@
|
||||
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때.",
|
||||
"methodSso": "{name}님은 링크를 사용할 때 통합 로그인(SSO)으로 로그인합니다.",
|
||||
"methodBoth": "{name}님은 비밀번호를 정하거나 통합 로그인(SSO)으로 로그인할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "{name} 님에 대해 기록한 모든 것이 프로필과 함께 사라져요. 측정값, 약, 문서, 메모 전부요. 프로필은 바로 잠기고 {days}일 뒤에 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "{name} 님의 기록에 저장했어요"
|
||||
@@ -12665,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} 님이 주소록 항목을 복원했어요",
|
||||
"illnessDayLogUpsert": "{name} 님이 아픈 날 하루를 기록했어요",
|
||||
"medicationUpdate": "{name} 님이 약을 수정했어요",
|
||||
"medicationCourseEnd": "{name} 님이 복용 기간을 끝냈어요",
|
||||
"medicationDelete": "{name} 님이 약을 삭제했어요",
|
||||
"medicationGlp1Update": "{name} 님이 GLP-1 요법을 수정했어요",
|
||||
"medicationScheduleCreate": "{name} 님이 복용 일정을 추가했어요",
|
||||
@@ -12712,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} 님이 복용 기록을 수정하려다 거절됐어요",
|
||||
"rejectedDoseImport": "{name} 님이 복용 기록을 불러오려다 거절됐어요",
|
||||
"rejectedTrackedValue": "{name} 님이 추적 값을 기록하려다 거절됐어요",
|
||||
"rejectedTrackedValueTypeCreate": "{name} 님이 추적 값 종류를 추가하려다 거절됐어요",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} 님이 추적 값 종류를 수정하려다 거절됐어요",
|
||||
"rejectedMedicationLayout": "{name} 님이 약 목록 순서를 바꾸려다 거절됐어요",
|
||||
"deniedDoseImport": "{name} 님이 시작한 복용 기록 불러오기를 대기열에 넣지 못했어요",
|
||||
"anamnesisFactCreate": "{name} 님이 건강 배경 항목을 추가했어요",
|
||||
"customMetricCreate": "{name} 님이 추적 값 종류를 추가했어요",
|
||||
@@ -13021,7 +13279,8 @@
|
||||
"backgroundScan": "백그라운드에서 읽고 있어요. 느린 모델이면 몇 분 걸릴 수 있어요.",
|
||||
"waitingForWorker": "백그라운드 작업자를 기다리고 있어요. 계속 그대로라면 서버의 작업자가 실행 중이 아닐 수 있어요.",
|
||||
"errorWorkerUnavailable": "지금은 백그라운드 작업자를 사용할 수 없어요. 잠시 후 다시 시도하거나 서버 운영자에게 확인을 요청하세요.",
|
||||
"errorTimedOut": "AI 응답 시간 설정보다 읽기가 오래 걸렸어요. 다시 시도하거나 AI 설정에서 응답 시간을 늘려 주세요."
|
||||
"errorTimedOut": "AI 응답 시간 설정보다 읽기가 오래 걸렸어요. 다시 시도하거나 AI 설정에서 응답 시간을 늘려 주세요.",
|
||||
"errorReaderBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "새 증상",
|
||||
|
||||
+339
-80
@@ -175,6 +175,9 @@
|
||||
"failed": "Het etiket kon niet worden gelezen. Probeer een scherpere foto."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn",
|
||||
"window": "geanalyseerd van {from} tot {to}, dit kan buiten de rapportperiode vallen",
|
||||
"windowLine": "Geanalyseerd van {from} tot {to}. Deze periode kan buiten de rapportperiode vallen."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} van {total} uitkomsten zijn beantwoord in een niet wetenschappelijk gevalideerde vertaling; zie ze als een indicatie."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Er is iets misgegaan",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Ongeldige uitnodiging",
|
||||
"description": "Deze uitnodigingslink is onvolledig of verkeerd gekopieerd. Vraag de persoon die je heeft uitgenodigd om een nieuwe link.",
|
||||
"toLogin": "Naar inloggen"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Dit account wordt op {date} verwijderd en is tot dan vergrendeld. Een beheerder van deze server kan de verwijdering vóór die datum ongedaan maken.",
|
||||
"deletionPendingNoDate": "Dit account staat gepland voor verwijdering en is vergrendeld. Een beheerder van deze server kan de verwijdering ongedaan maken tot die voltooid is."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Maak een back-up van je versleutelingssleutel",
|
||||
"body": "Zonder een kopie van de sleutel kunnen je gegevens na een herinstallatie niet worden hersteld. Het kost een minuutje.",
|
||||
"action": "Stap openen"
|
||||
},
|
||||
"quickAddSymptom": "Symptoom vastleggen",
|
||||
"todayCardVisible": "Bovenste kaart tonen"
|
||||
"todayCardVisible": "Bovenste kaart tonen",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Bewaar je herstelkit",
|
||||
"body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.",
|
||||
"action": "Kit openen"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Metingen",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Ik zie formuleringen die proberen mijn instructies te omzeilen. Ik ben de HealthLog Coach en vat alleen je eigen gezondheidsgegevens samen. Formuleer je vraag opnieuw binnen dat kader.",
|
||||
"errorBudget": "Je hebt de gebruikslimiet van de Coach voor vandaag bereikt. Het budget wordt om middernacht in jouw tijdzone vernieuwd.",
|
||||
"errorProvider": "De Coach kan op dit moment geen provider voor Inzichten bereiken. Probeer het zo opnieuw.",
|
||||
"errorReasoningOnly": "Het model heeft alleen nagedacht en geen antwoord geschreven. Probeer het opnieuw of kies een ander model in de AI-instellingen.",
|
||||
"errorDemo": "De Coach geeft in de demo geen antwoord.",
|
||||
"errorNoProvider": "De Coach leest je vastgelegde gezondheidsgegevens en beantwoordt vragen daarover (geen medisch advies). Daarvoor heeft HealthLog een AI-provider nodig. Koppel je eigen sleutel of een lokaal model.",
|
||||
"errorNoProviderAction": "AI-provider koppelen",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Wachtzin (min. 12 tekens)",
|
||||
"passphraseHelp": "Leidt de archiefsleutel af met Argon2id en versleutelt het archief met AES-256-GCM. Sla het bestand op met de extensie .hlx.",
|
||||
"noRecoveryWarning": "Herstellen is niet mogelijk. Als je deze wachtzin kwijtraakt, kan het archief niet worden geopend. Bewaar hem op een veilige plek.",
|
||||
"detail": "Zelfde opbouw als de wekelijkse automatische back-up. Sommige soorten gegevens worden nog niet meegenomen; het manifest van het bestand vermeldt wat erin zit.",
|
||||
"detail": "Een draagbare kopie van je dossier om te bewaren en te lezen: waarden als gewone JSON, documenten alleen als metadata. Het is niet de wekelijkse accountkopie die deze server bewaart om een account terug te zetten, en het kan geen account op een andere server opnieuw aanmaken. Sommige soorten gegevens zitten er nog niet in; het manifest van het bestand noemt wat erin staat.",
|
||||
"contents": "Je metingen, medicatie, stemming, cyclus, labuitslagen, ziekte-episodes, allergieën, familiegeschiedenis, voedingsstoffen, trainingen, gezondheidsprofiel en eigen meetwaarden in één JSON-bestand."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} cyclusdagen konden niet worden opgeslagen: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "JSON-bestand",
|
||||
"title": "Metingen en stemming (JSON)",
|
||||
"description": "Plak JSON of upload een .json-bestand met metingen en stemmingsregistraties.",
|
||||
"pasteLabel": "JSON plakken",
|
||||
"fileInputLabel": "Kies een JSON-bestand om te importeren",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Dat bestand kon niet worden gelezen.",
|
||||
"resultSummary": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd, {skipped} overgeslagen.",
|
||||
"resultSuccess": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd",
|
||||
"droppedLinks": "Geïmporteerd zonder deze tags, omdat dit account er geen met die naam heeft: {keys}",
|
||||
"keptLabels": "Dit account heeft al tags of groepen met deze sleutels onder een eigen naam, die zijn behouden: {keys}",
|
||||
"resultNothing": "Er is niets geïmporteerd. Alle {skipped} registraties zijn overgeslagen.",
|
||||
"resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties."
|
||||
"resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties.",
|
||||
"limitsHint": "Tot {mb} MB en tot {rows} items per lijst. Het voegt items toe aan je dossier; het zet geen volledige back-up terug."
|
||||
},
|
||||
"csv": {
|
||||
"title": "CSV-bestand",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporteer je gegevens of importeer ze uit een back-up."
|
||||
"subtitle": "Exporteer je gegevens, of importeer metingen en gegevens uit andere apps en bestanden."
|
||||
},
|
||||
"about": {
|
||||
"title": "Over deze app",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "Het token wordt één keer getoond.",
|
||||
"scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen. Het kan je gegevens niet lezen, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden, en je kunt ze nog steeds zelf corrigeren.",
|
||||
"createFailed": "Het token kon niet worden aangemaakt. Probeer het opnieuw.",
|
||||
"namePlaceholder": "Naam, bijv. Home Assistant"
|
||||
"namePlaceholder": "Naam, bijv. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Metingen naar Apple Health schrijven",
|
||||
"createHint": "De HealthLog-app op je iPhone zet de metingen van dit token dan in Apple Health. Je kunt dit later wijzigen in de tokenlijst hieronder.",
|
||||
"toggleAria": "Metingen van {name} naar Apple Health schrijven",
|
||||
"updateFailed": "De Apple Health-instelling kon niet worden gewijzigd. Probeer het opnieuw.",
|
||||
"unattributed": "Metingen die zijn verstuurd voordat deze instelling bestond, kunnen niet aan een token worden gekoppeld en blijven daarom buiten Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Trainingstokens",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Verwijdert je account samen met passkeys, auditlogboek en sessies.",
|
||||
"deleteAccountCta": "Account verwijderen",
|
||||
"deleteAccountConfirmTitle": "Account definitief verwijderen?",
|
||||
"deleteAccountConfirmDescription": "Je account, alle gezondheidsgegevens, passkeys, sessies en auditregistraties worden definitief verwijderd. Je wordt daarna direct uitgelogd.",
|
||||
"deleteAccountConfirmTitleGrace": "Je account verwijderen?",
|
||||
"deleteAccountConfirmDescription": "Je account, alle gezondheidsgegevens, passkeys, sessies en auditregistraties worden definitief verwijderd. Je wordt daarna direct uitgelogd. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
|
||||
"deleteAccountSuccess": "Account verwijderd. Je wordt uitgelogd.",
|
||||
"deleteAccountFailed": "Account kon niet worden verwijderd",
|
||||
"deleteAccountFinal": "Definitief verwijderen",
|
||||
"deleteAccountFinalGrace": "Account verwijderen",
|
||||
"saved": "Opgeslagen",
|
||||
"savingError": "Fout bij opslaan",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Coach-gesprekken: {days} dagen bewaard",
|
||||
"audit": "Logboek van beveiligingsactiviteit: {days} dagen bewaard",
|
||||
"delivery": "Logboek van bezorgde meldingen: {days} dagen bewaard",
|
||||
"unavailable": "Details over de bewaartermijn zijn op dit moment niet beschikbaar."
|
||||
"unavailable": "Details over de bewaartermijn zijn op dit moment niet beschikbaar.",
|
||||
"accountDeletion": "Je account, een beheerd profiel of alle gegevens verwijderen: voltooid na {days} dagen",
|
||||
"accountDeletionImmediate": "Je account, een beheerd profiel of alle gegevens verwijderen: meteen voltooid"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Verwijderen of wissen",
|
||||
"description": "Verwijder je account volledig, of wis je gezondheidsgegevens en behoud je account.",
|
||||
"backupLag": "Bij verwijderen worden je gegevens direct gewist. Als deze server versleutelde back-ups buiten de host bewaart, worden de kopieën van je gegevens direct daarna uit die opslag verwijderd, of bij de volgende nachtelijke run als de opslag niet bereikbaar is.",
|
||||
"openAdvanced": "Verwijderen of wissen"
|
||||
"openAdvanced": "Verwijderen of wissen",
|
||||
"immediateNotice": "Bij verwijderen worden je gegevens direct gewist. Als deze server versleutelde back-ups buiten de host bewaart, worden de kopieën van je gegevens direct daarna uit die opslag verwijderd, of bij de volgende nachtelijke run als de opslag niet bereikbaar is. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
|
||||
"graceNotice": "Als je je account verwijdert, wordt het meteen vergrendeld en na {days} dagen gewist, samen met elke kopie die deze server ervan bewaart, ook versleutelde kopieën buiten de server. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken. Alle gegevens verwijderen werkt net zo: een kopie van het dossier wordt {days} dagen bewaard, zodat een beheerder die kan terugzetten. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Privacyniveau",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Uit",
|
||||
"change": "Wijzigen in Modules"
|
||||
},
|
||||
"timezoneUseDevice": "Tijdzone van dit apparaat gebruiken ({zone})"
|
||||
"timezoneUseDevice": "Tijdzone van dit apparaat gebruiken ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Hiermee wordt alles in je dossier verwijderd: metingen, medicatie, stemmingsregistraties, labuitslagen, cyclusregistraties, documenten, AI-gesprekken en alle koppelingen, samen met alles wat daarvan is afgeleid en alles wat je eerder hebt verwijderd. Deellinks die je aan een arts hebt gegeven, werken direct niet meer. Je account blijft bestaan, net als de inloggegevens waarmee je inlogt en weergave-instellingen zoals taal, tijdzone en eenheden. Een kopie van je dossier wordt {days} dagen bewaard, zodat een beheerder van deze server die tot dan kan terugzetten. Daarna is de verwijdering definitief.",
|
||||
"deleteAccountCardDetailGrace": "Je account wordt meteen vergrendeld en na {days} dagen gewist. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Beheer",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Je kunt jezelf niet geforceerd uitloggen",
|
||||
"emptyTitle": "Geen gebruikers in deze weergave",
|
||||
"emptyDescription": "Er zijn geen gebruikers die aan het huidige filter voldoen.",
|
||||
"emptyResetFilter": "Alle gebruikers tonen"
|
||||
"emptyResetFilter": "Alle gebruikers tonen",
|
||||
"deletionPending": "Verwijdering op {date}",
|
||||
"deletionPendingNoDate": "Verwijdering gepland",
|
||||
"undoDeletion": "Verwijdering ongedaan maken",
|
||||
"undoDeletionTitle": "Verwijdering van {name} ongedaan maken?",
|
||||
"undoDeletionBody": "{name} heeft gevraagd dit account te verwijderen; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het account en alles erin zoals het was. {name} kan weer inloggen, maar API-tokens, deellinks en vertrouwde apparaten blijven ingetrokken en moeten opnieuw worden ingesteld.",
|
||||
"undoDeletionBodyGuardian": "Een verzorger heeft het beheerde profiel {name} verwijderd; het wordt gewist als de bedenktijd afloopt. Ongedaan maken houdt het profiel en alles erin zoals het was, en de verzorgers kunnen het weer bereiken. De deellinks en tokens blijven ingetrokken en moeten opnieuw worden ingesteld.",
|
||||
"undoDeletionDone": "Verwijdering van {name} ongedaan gemaakt",
|
||||
"undoDeletionFailed": "Verwijdering kon niet ongedaan worden gemaakt"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Uitnodigingen",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Back-ups",
|
||||
"subtitle": "Wekelijkse snapshots van de gegevens per gebruiker, opgeslagen in PostgreSQL.",
|
||||
"description": "Elke gebruiker krijgt per back-uptype één doorlopende snapshot. De getoonde grootte is die van de versleutelde blob. Bij downloaden wordt de snapshot ontsleuteld voor de beheerder die erom vraagt; de versleutelde gegevens blijven in de database.",
|
||||
"subtitle": "Accountkopieën in de database van deze server, om één account op deze server terug te zetten.",
|
||||
"description": "Elk account heeft één wekelijkse kopie, die elke zondag wordt vervangen, plus kopieën die hier zijn geüpload en de kopie die vóór 'Alle gegevens verwijderen' wordt gemaakt. Een kopie zet een bestaand account op deze server terug. Een verloren server kan ze niet herbouwen: daarvoor zijn een databasedump en de encryptiesleutels nodig. De groottes gelden voor de versleutelde kopie. Downloaden ontsleutelt die voor de beheerder die erom vraagt; de versleutelde tekst blijft in de database.",
|
||||
"runNow": "Nu back-up maken",
|
||||
"runEnqueued": "Back-uptaak in de wachtrij gezet",
|
||||
"runFailed": "Back-up in de wachtrij zetten mislukt",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Back-ups worden automatisch elke zondag om 03:00 gemaakt. Je kunt er ook nu een starten om de eerste snapshot te maken.",
|
||||
"colUser": "Gebruiker",
|
||||
"colType": "Type",
|
||||
"typeWeeklyAuto": "Wekelijks (automatisch)",
|
||||
"typeWeeklyAuto": "Wekelijkse accountkopie",
|
||||
"typeManual": "Handmatig",
|
||||
"colSize": "Grootte",
|
||||
"colCreatedAt": "Laatste snapshot",
|
||||
"colActions": "Acties",
|
||||
"retentionLabel": "Bewaartermijn: {days} dagen",
|
||||
"scheduleStaleTitle": "De wekelijkse back-up is gestopt",
|
||||
"scheduleStaleDescription": "De nieuwste geplande kopie is {days} dagen oud. Een goed werkende wekelijkse run laat nooit een kopie ouder worden dan {threshold} dagen.",
|
||||
"scheduleFailedTitle": "De laatste geplande back-uprun is mislukt",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Downloaden mislukt",
|
||||
"downloadStarted": "Download gestart",
|
||||
"uploadTitle": "Back-up uploaden",
|
||||
"uploadDescription": "Controleer een back-upbestand en sla het op als handmatige snapshot. Terugzetten is een aparte stap.",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.",
|
||||
"uploadButton": "Bestand uploaden",
|
||||
"uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.",
|
||||
"uploadSuccess": "Back-up geüpload ({count} records)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Accountinstelling",
|
||||
"restoreMissingTitle": "Terugzetten geweigerd: het bestand is onvolledig",
|
||||
"restoreMissingDescription": "Volgens het eigen manifest van het bestand bevat het de onderstaande sectie, maar die ontbreekt. Er is niets gewijzigd: terugzetten vervangt een complete categorie records, dus het terugzetten van een sectie die niet in het bestand staat, zou die categorie hebben leeggemaakt.",
|
||||
"restoreMissingRemedy": "Gebruik een snapshot voor noodherstel, of exporteer het account opnieuw en upload het nieuwe bestand. Een sectie die het manifest als weggelaten aanmerkt, is iets anders en wordt normaal teruggezet.",
|
||||
"restoreMissingRemedy": "Gebruik een van de accountkopieën van deze server of een kopie buiten de server, of exporteer het account opnieuw en upload het nieuwe bestand. Een sectie die het manifest als weggelaten opgeeft, is iets anders en wordt gewoon teruggezet.",
|
||||
"restoreMissingDocuments": "Documenten",
|
||||
"restoreMissingWorkouts": "Trainingen",
|
||||
"restoreMissingMentalHealth": "Vragenlijsten mentale gezondheid",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Er wordt al een back-up van dit account teruggezet. Wacht tot dat klaar is.",
|
||||
"restoreJobQueuedTitle": "Terugzetten van {username} wacht op start",
|
||||
"restoreJobQueuedNote": "Het begint zodra de achtergrondworker het oppakt.",
|
||||
"restoreJobWaitingBackupNote": "Wacht op de back-up die nu op de server draait. Terugzetten draait nooit naast een back-up en begint ongeveer een minuut nadat die klaar is.",
|
||||
"restoreJobWaitingBusyNote": "Wacht tot een grote taak op de server klaar is, zoals een import of een nachtelijke run. Het begint ongeveer een minuut daarna.",
|
||||
"restoreJobQueuedSince": "In de wachtrij sinds {when}.",
|
||||
"restoreJobRunningTitle": "{username} wordt teruggezet",
|
||||
"restoreJobRunningNote": "Het account toont de huidige gegevens tot het terugzetten klaar is. Je kunt deze pagina verlaten; het terugzetten gaat op de server verder.",
|
||||
"restoreJobResumed": "Opnieuw gestart nadat de server is herstart.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "Het bestand in de back-up hoort bij een ander account dan de back-uprij.",
|
||||
"restoreFailureOwnerMissing": "Het account waar deze back-up bij hoort bestaat niet meer.",
|
||||
"restoreFailureSectionMissing": "Het bestand is onvolledig: het manifest noemt een sectie die er niet in staat.",
|
||||
"restoreFailureDocument": "Het bestand bevat documenten zonder hun inhoud, zoals bij een overdraagbare export. Gebruik een back-up voor noodherstel.",
|
||||
"restoreFailureDocument": "Het bestand bevat documenten zonder hun inhoud, zoals een draagbare export. Gebruik in plaats daarvan een van de accountkopieën van deze server of een kopie buiten de server.",
|
||||
"restoreFailureTimeBudget": "Deze back-up is te groot om binnen de tijdslimiet van de achtergrondtaak terug te zetten.",
|
||||
"restoreFailureTransaction": "De database weigerde het terugzetten halverwege, dus alles is teruggedraaid. In het serverlog staat de fout.",
|
||||
"restoreFailureCauseTimeout": "Het terugzetten duurde langer dan de database toestond, dus alles is teruggedraaid. Als de databasehost druk was, start het terugzetten dan opnieuw wanneer het rustiger is. In het serverlog staat de stap.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Stemmingslabel of groepsnaam versleuteld met een sleutel die deze server niet heeft (behouden zonder naam)",
|
||||
"restoreSkippedVisitCiphertext": "Tekst van bezoek of zorgverlener versleuteld met een sleutel die deze server niet heeft (behouden zonder)",
|
||||
"restoreSkippedVaccinationCiphertext": "Vaccinatienotitie versleuteld met een sleutel die deze server niet heeft (dosis behouden zonder)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)",
|
||||
"typePreWipe": "Voor het verwijderen van alle gegevens",
|
||||
"retentionNote": "De wekelijkse kopie wordt elke zondag vervangen. Een kopie die vóór 'Alle gegevens verwijderen' is gemaakt, wordt na {days} dagen verwijderd. Geüploade kopieën blijven tot hun account gewist wordt.",
|
||||
"retentionNoteNoGrace": "De wekelijkse kopie wordt elke zondag vervangen. Geüploade kopieën blijven tot hun account gewist wordt.",
|
||||
"database": {
|
||||
"title": "Databasedump",
|
||||
"lastGood": "Laatste geldige dump {when}, {size}, {where}.",
|
||||
"whereBoth": "bewaard op deze server en extern",
|
||||
"whereLocal": "bewaard op deze server",
|
||||
"whereOffhost": "alleen extern",
|
||||
"none": "Nog geen databasedump. De eerste wordt vannacht gemaakt.",
|
||||
"failedTitle": "De laatste databasedump is mislukt",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Databasedumps staan uit (DB_BACKUP_ENABLED=false). Maak op een andere manier een back-up van de database.",
|
||||
"noDirectoryOffhost": "Deze server kan niet schrijven naar {dir}, dus dumps gaan alleen extern en hier wordt er geen bewaard. Koppel het back-upvolume.",
|
||||
"noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume.",
|
||||
"retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} nachtelijke dumps bewaard, en de dump van vóór de laatste update blijft tot de volgende update.",
|
||||
"retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.",
|
||||
"retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.",
|
||||
"notPersistentOffhost": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen), dus een dump die daar wordt geschreven gaat bij de volgende update verloren. Dumps gaan alleen extern: hier wordt er geen bewaard en vóór een migratie wordt er geen gemaakt. Koppel het back-upvolume.",
|
||||
"notPersistent": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen) en er is geen externe bucket ingesteld, dus nergens wordt een databasedump bewaard. Koppel het back-upvolume.",
|
||||
"offhostFailedTitle": "De nieuwste databasedump is niet in de externe bucket aangekomen",
|
||||
"offhostLastUpload": "De nieuwste dump in de bucket is van {when}.",
|
||||
"offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen."
|
||||
},
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gevarenzone",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Tabel verbergen",
|
||||
"columnsShowAll": "Alle {count} kolommen tonen",
|
||||
"columnsShowWithRows": "Alleen kolommen met rijen",
|
||||
"columnsNoneWithRows": "Nog geen enkele versleutelde kolom bevat rijen."
|
||||
"columnsNoneWithRows": "Nog geen enkele versleutelde kolom bevat rijen.",
|
||||
"backupsDumpLocalLine": "Sleutel {key}: {count} databasedumps op deze server zijn onder deze sleutel geschreven; hij is nodig tot de bewaartermijn ze verwijdert.",
|
||||
"backupsDumpOffhostLine": "Sleutel {key}: de nieuwste databasedump onder deze sleutel is op {when} in de bucket gezet; daar nodig tot {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Sleutel {key}: de nieuwste databasedump onder deze sleutel is op {when} in de bucket gezet. Hoe lang de bucket database/ bewaart, is onbekend, dus bewaar de sleutel tot je zeker weet dat die dump weg is."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Onderhoud van de metingentabel",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Dit deel van de pagina kon niet worden weergegeven",
|
||||
"description": "De rest van de pagina werkt nog; probeer het opnieuw of herlaad de pagina als het blijft mislukken."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Maak een back-up van je versleutelingssleutel",
|
||||
"description": "Zonder een kopie van deze sleutel kunnen je versleutelde gegevens en de back-ups van deze server niet meer worden gelezen.",
|
||||
"loading": "Status van de sleutelback-up laden…",
|
||||
"loadError": "De status van de sleutelback-up kon niet worden geladen.",
|
||||
"badgeDue": "Te doen",
|
||||
"badgeDone": "Back-up gemaakt",
|
||||
"keyId": "Actieve sleutel-ID",
|
||||
"fingerprint": "Vingerafdruk",
|
||||
"whatIsLost": "Bewaar een kopie van de sleutel ergens buiten deze server, bijvoorbeeld in een wachtwoordmanager. Als de server opnieuw wordt geïnstalleerd met een andere sleutel, weigert HealthLog te starten tot de oorspronkelijke sleutel terug is.",
|
||||
"fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van de sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.",
|
||||
"checkLabel": "Mijn kopie controleren",
|
||||
"checkButton": "Controleren",
|
||||
"checkNote": "De sleutel die je plakt, wordt vergeleken met de actieve sleutel en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.",
|
||||
"checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.",
|
||||
"checkMatches": "Je kopie komt overeen met de actieve sleutel.",
|
||||
"checkNoMatch": "Je kopie komt niet overeen met de actieve sleutel. Controleer of je de hele waarde hebt gekopieerd.",
|
||||
"checkFailed": "De controle is mislukt.",
|
||||
"confirmButton": "Ik heb een back-up van deze sleutel gemaakt",
|
||||
"confirmed": "Sleutelback-up bevestigd.",
|
||||
"confirmFailed": "De bevestiging kon niet worden opgeslagen.",
|
||||
"stale": "De sleutel van de server is gewijzigd sinds deze pagina is geladen. Controleer de nieuwe sleutel-ID en vingerafdruk en bevestig daarna opnieuw.",
|
||||
"confirmedAt": "Bevestigd op {date}.",
|
||||
"confirmedAtBy": "Bevestigd op {date} door {who}.",
|
||||
"dueAgain": "Er is een back-up bevestigd voor sleutel {keyId}, maar de server gebruikt nu een andere sleutel. Maak een back-up van de huidige sleutel.",
|
||||
"tileLabel": "Back-up van versleutelingssleutel",
|
||||
"tileDue": "Niet bevestigd",
|
||||
"tileDone": "Bevestigd",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "De sleutel is ENCRYPTION_KEY (of ENCRYPTION_KEYS) in het .env-bestand naast docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog, kies Edit en kopieer ENCRYPTION_KEY uit de omgevingsinstellingen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY uit de Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY uit de omgevingsvariabelen."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog kan zijn gegevens niet openen",
|
||||
"body": "De versleutelingssleutel waarmee deze server is gestart, is niet de sleutel waarmee de database is geschreven. Om je gegevens te beschermen, levert HealthLog niets uit totdat dit is opgelost.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "Het standaardniveau kan niet hoger zijn dan het hoogste niveau.",
|
||||
"gapLabel": "Uren tussen het vernieuwen van kaarten na een synchronisatie (Standaard)",
|
||||
"gapHint": "Hele uren van 1 tot 48. Laat leeg om AI_SYNC_REGEN_GAP_HOURS te gebruiken, anders 6. Nu van kracht: {hours} u."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Herstelkit",
|
||||
"description": "Elke sleutel die nodig is om deze server terug te zetten, met ID en vingerafdruk.",
|
||||
"intro": "Bewaar van elke sleutel hieronder een kopie buiten deze server, bijvoorbeeld in een wachtwoordmanager. Zonder die kopieën kunnen een databasedump en een back-upkopie niet meer worden gelezen, en HealthLog weigert te starten als een benodigde sleutel ontbreekt.",
|
||||
"loading": "Herstelkit laden…",
|
||||
"loadError": "De herstelkit kon niet worden geladen.",
|
||||
"badgeDue": "Te doen",
|
||||
"badgeDone": "Bewaard",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Vingerafdruk",
|
||||
"noFingerprint": "Niet op deze server",
|
||||
"kind": {
|
||||
"application": "Applicatiesleutel {id}",
|
||||
"backup": "Sleutel voor externe back-ups",
|
||||
"backupPrevious": "Vorige sleutel voor externe back-ups",
|
||||
"token": "Tokensleutel"
|
||||
},
|
||||
"need": {
|
||||
"active": "Versleutelt nieuwe gegevens.",
|
||||
"configured": "Staat op deze server.",
|
||||
"storedCopies": "Nodig voor {count} opgeslagen kopieën.",
|
||||
"offhostUntil": "Nodig voor externe kopieën tot {date}.",
|
||||
"offhostUnknown": "Nodig voor externe kopieën zolang de bucket ze bewaart.",
|
||||
"backupActive": "Verzegelt elke nieuwe externe kopie.",
|
||||
"backupPrevious": "Opent externe kopieën die vóór de laatste sleutelwissel zijn geschreven.",
|
||||
"token": "Ondertekent sessies, tokens en deellinks. Zonder deze sleutel wordt iedereen afgemeld, maar gaan er geen gegevens verloren.",
|
||||
"localDumps": "Nodig voor {count} databasedumps op deze server.",
|
||||
"offhostDumpsUntil": "Nodig voor externe databasedumps tot {date}.",
|
||||
"offhostDumpsUnknown": "Nodig voor externe databasedumps zolang de bucket ze bewaart."
|
||||
},
|
||||
"source": "Ingesteld in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Bewaard",
|
||||
"new": "Nog niet bewaard",
|
||||
"notConfigured": "Niet op deze server",
|
||||
"unreadable": "Geen geldige sleutel"
|
||||
},
|
||||
"notConfiguredHint": "Een back-up heeft deze sleutel nog nodig, maar deze server heeft hem niet. Alleen een kopie die je elders bewaart, kan die back-ups lezen. Bevestig de kit zodra je die kopie hebt.",
|
||||
"unreadableHint": "De variabele is ingesteld, maar bevat geen geldige sleutel. Los dat op voordat je op de externe kopieën vertrouwt.",
|
||||
"fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van een sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.",
|
||||
"whereTitle": "Waar de sleutels zijn ingesteld",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "De sleutels staan in het .env-bestand naast docker-compose.yml: ENCRYPTION_KEY of ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY en, bij externe kopieën, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutels staan onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, zijn het ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY in de omgevingsinstellingen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de omgevingsvariabelen."
|
||||
},
|
||||
"restoreTitle": "Deze server terugzetten",
|
||||
"restoreBody": "Installeer HealthLog met dezelfde sleutels, zet de nieuwste databasedump terug in een lege database en start dan de app. De handleiding loopt elke stap door.",
|
||||
"restoreLink": "Handleiding voor noodherstel",
|
||||
"checkLabel": "Mijn kopie controleren",
|
||||
"checkKeyLabel": "Te controleren sleutel",
|
||||
"checkValueLabel": "Jouw kopie van de sleutel",
|
||||
"checkButton": "Controleren",
|
||||
"checkNote": "De sleutel die je plakt, wordt vergeleken met die op deze server en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.",
|
||||
"checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.",
|
||||
"checkMatches": "Je kopie komt overeen met deze sleutel.",
|
||||
"checkNoMatch": "Je kopie komt niet overeen met deze sleutel. Controleer of je de hele waarde hebt gekopieerd.",
|
||||
"checkFailed": "De controle is mislukt.",
|
||||
"confirmButton": "Ik heb een kopie van elke sleutel",
|
||||
"confirmed": "Herstelkit bevestigd.",
|
||||
"confirmFailed": "De bevestiging kon niet worden opgeslagen.",
|
||||
"stale": "De kit is gewijzigd sinds deze pagina is geladen. Controleer de sleutels die er nu in staan en bevestig daarna opnieuw.",
|
||||
"confirmedAt": "Bevestigd op {date}.",
|
||||
"confirmedAtBy": "Bevestigd op {date} door {who}.",
|
||||
"dueAgain": "De kit is bevestigd op {date}. Hij vermeldt nu ook {keys}, die die bevestiging niet dekte. Bewaar van elk een kopie en bevestig opnieuw."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Herstelbaarheid",
|
||||
"description": "Of deze server na verlies weer op te bouwen is, beoordeeld op basis van elke back-up die hij maakt.",
|
||||
"loading": "Back-ups controleren…",
|
||||
"loadError": "De herstelbaarheid kon niet worden geladen.",
|
||||
"state": {
|
||||
"ok": "Gereed",
|
||||
"attention": "Aandacht nodig",
|
||||
"failing": "In gevaar"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Databasedump",
|
||||
"weekly_copy": "Wekelijkse accountkopieën",
|
||||
"offhost_copies": "Externe kopieën",
|
||||
"restore_drill": "Hersteltest",
|
||||
"offhost_setup": "Externe back-up",
|
||||
"recovery_kit": "Herstelkit",
|
||||
"dump_retention": "Dumps in de bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Nieuwste dump: {date}",
|
||||
"weekly_copy": "Oudste accountkopie: {date}",
|
||||
"offhost_copies": "Oudste externe kopie: {date}",
|
||||
"restore_drill": "Laatste test: {date}",
|
||||
"recovery_kit": "Bevestigd: {date}"
|
||||
},
|
||||
"dueBy": "Uiterlijk {date}",
|
||||
"fix": {
|
||||
"database_dump": "Back-ups openen",
|
||||
"weekly_copy": "Back-ups openen",
|
||||
"offhost_copies": "Back-ups openen",
|
||||
"restore_drill": "Back-ups openen",
|
||||
"offhost_setup": "Back-ups openen",
|
||||
"recovery_kit": "Kit openen",
|
||||
"dump_retention": "Back-ups openen"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Deze versie plant geen databasedump in.",
|
||||
"disabled": "De geplande databasedump staat uit, dus geen enkele kopie kan deze server opnieuw opbouwen. Verwijder DB_BACKUP_ENABLED of zet het op true en herstart HealthLog om hem weer aan te zetten.",
|
||||
"unreadable": "De status van de databasedump kon niet worden gelezen.",
|
||||
"never_succeeded": "Er is nog geen databasedump gelukt, en de laatste poging is mislukt.",
|
||||
"never_ran": "In de {hours} uur sinds dumps zijn ingeschakeld, is er geen databasedump geschreven.",
|
||||
"first_pending": "De eerste databasedump is nog niet uitgevoerd.",
|
||||
"stale": "De nieuwste databasedump is {hours} uur oud.",
|
||||
"last_failed": "De laatste databasedump is mislukt. De dump daarvoor is nog recent.",
|
||||
"fresh": "De nieuwste databasedump is recent.",
|
||||
"nowhere_to_write": "De dumpmap {directory} is niet beschrijfbaar en er is geen externe bucket ingesteld, dus er kan geen dump worden bewaard.",
|
||||
"not_persistent": "De dumpmap {directory} staat niet op blijvende opslag en er is geen externe bucket ingesteld, dus elke dump gaat bij de volgende update verloren. Koppel het back-upvolume.",
|
||||
"fresh_offhost_only": "De nieuwste databasedump is recent en staat in de externe bucket. Op deze server wordt er geen bewaard, omdat {directory} niet op blijvende opslag staat, en vóór een migratie wordt er geen gemaakt.",
|
||||
"offhost_failed": "De nieuwste databasedump staat op deze server, maar het uploaden naar de externe bucket is mislukt.",
|
||||
"offhost_stale": "Al {hours} uur is er geen databasedump in de externe bucket aangekomen: het uploaden blijft mislukken. Gaat deze server verloren, dan is er geen recente dump om hem mee te herstellen.",
|
||||
"offhost_never": "Er is nog nooit een databasedump in de externe bucket aangekomen: het uploaden mislukt. Gaat deze server verloren, dan is er geen dump om hem mee te herstellen."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Er zijn nog geen accounts om te kopiëren.",
|
||||
"stale": "De oudste wekelijkse kopie is {days} dagen oud. Achterlopende accounts: {accounts}.",
|
||||
"never": "Een account heeft al {days} dagen geen wekelijkse kopie. Achterlopende accounts: {accounts}.",
|
||||
"first_pending": "De eerste wekelijkse kopie wordt vroeg op zondag gemaakt.",
|
||||
"fresh": "Elk account heeft een kopie van de afgelopen week."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Er is geen externe bucket ingesteld.",
|
||||
"stale": "Externe kopieën lopen meer dan twee nachten achter. Achterlopende accounts: {accounts}.",
|
||||
"first_pending": "De nachtelijke run voor externe kopieën heeft nog geen enkel account bereikt.",
|
||||
"fresh": "Elk account heeft een recente kopie in de externe bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "De test leest de externe bucket, en die is niet ingesteld.",
|
||||
"not_run_yet": "De maandelijkse hersteltest is nog niet uitgevoerd.",
|
||||
"failed": "De laatste hersteltest is mislukt: {failed} van {checked} kopieën konden niet worden teruggelezen.",
|
||||
"failed_run": "De laatste hersteltest kon niet worden afgerond.",
|
||||
"passed": "De laatste hersteltest heeft {checked} kopieën teruggelezen.",
|
||||
"nothing_checked": "De laatste hersteltest stopte op zijn tijdslimiet voordat hij ook maar één kopie had teruggelezen, en heeft dus niets aangetoond."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopieën verlaten deze server elke nacht.",
|
||||
"no_admin": "Er is nog geen beheerdersaccount.",
|
||||
"not_configured": "Er is geen externe bucket ingesteld, dus elke kopie staat alleen op deze server.",
|
||||
"not_configured_grace": "Er is nog geen externe bucket ingesteld, dus elke kopie staat alleen op deze server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Van elke sleutel die een herstel nodig heeft, wordt een kopie bewaard.",
|
||||
"out_of_date": "De herstelkit vermeldt nu ook {keys}, die de laatste bevestiging niet dekte. Er is niets kapot: bewaar een kopie en bevestig de kit opnieuw.",
|
||||
"no_admin": "Er is nog geen beheerdersaccount.",
|
||||
"not_confirmed": "Niemand heeft een kopie bevestigd van de sleutels die een herstel nodig heeft: {keys}.",
|
||||
"not_confirmed_grace": "Bewaar een kopie van de sleutels die een herstel nodig heeft: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Er is geen externe bucket ingesteld.",
|
||||
"no_offhost_dumps": "Er gaat geen databasedump naar de bucket.",
|
||||
"missing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.",
|
||||
"configured": "De bucket verwijdert dumps na {days} dagen.",
|
||||
"unknown": "De bucket zegt niet of een regel oude dumps verwijdert. Controleer of een levenscyclusregel database/ dekt."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "De databasedump is weer recent.",
|
||||
"weekly_copy": "Elk account heeft weer een recente wekelijkse kopie.",
|
||||
"offhost_copies": "Elk account heeft weer een recente externe kopie.",
|
||||
"restore_drill": "De hersteltest is weer geslaagd.",
|
||||
"offhost_setup": "Externe kopieën zijn nu ingesteld.",
|
||||
"recovery_kit": "De herstelkit is weer bevestigd.",
|
||||
"dump_retention": "Een levenscyclusregel in de bucket dekt nu de databasedumps."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Offline geolocatiedatabases niet geladen",
|
||||
"offlineGeoUnavailableBody": "HealthLog bepaalt inloglocaties via de online opzoekdienst op {host}, dus elk IP-adres waarmee wordt ingelogd, verlaat deze server. Wil je ze lokaal bepalen, download dan de databases GeoLite2-City en GeoLite2-ASN met een gratis MaxMind-account, koppel de map aan de container en laat GEOLITE2_DIR ernaar verwijzen. Stappen: {docsUrl}",
|
||||
"tlsPinChangedTitle": "TLS-leafcertificaat gewijzigd: {host}",
|
||||
"tlsPinChangedBody": "Het TLS-leafcertificaat dat {host} aanbiedt, heeft nu SPKI-pin {servedPin}, die niet in de set bekende pins staat. Clients met pinning (de native app) kunnen geen verbinding meer maken zodra de oude pin verdwenen is. Haal de nieuwe leaf-pin opnieuw op, voeg hem toe aan TLS_LEAF_SPKI_PINS en aan de pinset van de native client, en breng een build uit voordat dit certificaat op {validTo} verloopt. Zie docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "Het TLS-leafcertificaat dat {host} aanbiedt, heeft nu SPKI-pin {servedPin}, die niet in de set bekende pins staat. Clients met pinning (de native app) kunnen geen verbinding meer maken zodra de oude pin verdwenen is. Haal de nieuwe leaf-pin opnieuw op, voeg hem toe aan TLS_LEAF_SPKI_PINS en aan de pinset van de native client, en breng een build uit voordat dit certificaat op {validTo} verloopt. Zie docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Account kon niet worden gewist: {username}",
|
||||
"accountErasureRefusedBody": "De verwijdertermijn van {username} is afgelopen, maar het account is de laatste actieve verzorger van een beheerd profiel en is daarom niet gewist. Het blijft vergrendeld. Voeg een andere verzorger toe aan het profiel, verwijder het profiel of maak de verwijdering ongedaan in Beheer, Gebruikers.",
|
||||
"accountErasureFailedBody": "De verwijdertermijn van {username} is afgelopen, maar het wissen van het account is mislukt. Het blijft vergrendeld en wordt elk uur opnieuw geprobeerd, na de andere accounts die aan de beurt zijn. De fout staat op de kaart met mislukte taken in Beheer."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Herinneringen voor controles",
|
||||
"description": "Zet afzonderlijke herinneringen voor controles aan of uit."
|
||||
},
|
||||
"eventBackupHealth": "Status van back-ups",
|
||||
"eventBackupHealthDesc": "Wordt verstuurd als een back-up stopt, achterloopt of weer bijgewerkt is. Alleen voor beheerders.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Back-ups hebben aandacht nodig",
|
||||
"titleFailing": "Back-ups lopen gevaar",
|
||||
"titleReminder": "Back-ups lopen nog steeds gevaar",
|
||||
"titleRecovered": "Back-ups zijn weer in orde"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11054,7 +11269,6 @@
|
||||
"who5": "Een welzijnsscore van 50 of lager kan het waard zijn om beter naar te kijken. Als je wilt, gaat de PHQ-9-check-in op deze pagina dieper in op je stemming. Dit is een voorzichtige suggestie, geen diagnose.",
|
||||
"sci": "In het onderzoek achter deze vragenlijst hingen scores van 16 of lager samen met waarschijnlijke slapeloosheid. Blijf je last houden van slecht slapen, dan kan een gesprek met een zorgverlener helpen. Dit is geen diagnose."
|
||||
},
|
||||
"validatedInEnglishNote": "Deze vragenlijst is in het Engels gevalideerd; de vragen worden in de oorspronkelijke Engelse formulering getoond.",
|
||||
"openDetail": "Details tonen",
|
||||
"historyLoadError": "Je testgeschiedenis kon niet worden geladen",
|
||||
"historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.",
|
||||
@@ -11104,6 +11318,13 @@
|
||||
"belowThreshold": "Je slaap in de afgelopen maand lag in het bereik waarin het onderzoek achter deze vragenlijst slapeloosheid waarschijnlijk vond.",
|
||||
"aboveThreshold": "Je slaap in de afgelopen maand lag buiten het bereik dat dat onderzoek met slapeloosheid verbindt."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Deze vertaling is niet wetenschappelijk gevalideerd; zie de uitkomst als een indicatie.",
|
||||
"showOriginal": "Oorspronkelijke Engelse tekst tonen",
|
||||
"showTranslation": "Vertaling tonen",
|
||||
"resultUnvalidated": "Beantwoord in een vertaling die niet wetenschappelijk gevalideerd is; zie de uitkomst als een indicatie.",
|
||||
"historyUnvalidated": "Beantwoord in een niet wetenschappelijk gevalideerde vertaling"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11631,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Al opgeslagen. Het bestaande document is gemarkeerd.",
|
||||
"deleted": "Document verwijderd",
|
||||
"restoreFailed": "Het document kon niet worden hersteld."
|
||||
"restoreFailed": "Het document kon niet worden hersteld.",
|
||||
"deletedHint": "Verwijderde documenten blijven {days} dagen in Recent verwijderd."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Meer laden…",
|
||||
@@ -11676,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "De AI-provider gaf geen tekst terug voor dit document."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Dit document kon niet worden geladen.",
|
||||
@@ -11748,7 +11971,7 @@
|
||||
"dismiss": "Negeren",
|
||||
"empty": "Er kon niets uit dit document worden gelezen.",
|
||||
"unavailableBody": "Stel een AI-provider in om een titel, soort en datum voor te stellen.",
|
||||
"unavailableLocalOcr": "Zet lokale OCR aan in de AI-instellingen om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableLocalOcr": "Zet “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableAction": "AI-instellingen openen",
|
||||
"errorGeneric": "Het document kon niet worden gelezen. Probeer het opnieuw.",
|
||||
"errorOcr": "Er kon geen tekst uit deze afbeelding worden gelezen.",
|
||||
@@ -11760,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Voor het scannen van PDF's is een Claude-provider met beeldherkenning nodig.",
|
||||
"errorFileType": "Dit bestandstype kan niet worden gelezen.",
|
||||
"errorNotIndexed": "Laat het document eerst lezen en extraheer daarna.",
|
||||
"errorLocalOcrDisabled": "Zet eerst lokale OCR aan in de AI-instellingen.",
|
||||
"errorOutputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een model dat langere antwoorden toestaat.",
|
||||
"errorLocalOcrDisabled": "Zet eerst “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden.",
|
||||
"errorProvider": "Hiervoor is geen AI-provider ingesteld.",
|
||||
"errorTextImageOnly": "Lokale OCR leest alleen afbeeldingen, niet dit bestand.",
|
||||
"errorConsent": "Voor het lezen van documenten met AI is eerst je toestemming nodig.",
|
||||
"errorUnavailable": "Documenten lezen met AI is hier op dit moment niet beschikbaar."
|
||||
"errorUnavailable": "Documenten lezen met AI is hier op dit moment niet beschikbaar.",
|
||||
"unavailableLocalOcrAction": "Instellingen voor labwaarden openen"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Samenvatten",
|
||||
@@ -11820,6 +12045,7 @@
|
||||
"extracting": "Extraheren…",
|
||||
"extractHint": "Gebruikt de tekst die al uit dit document is gelezen. Geëxtraheerde waarden wachten op jouw controle; er wordt niets automatisch opgeslagen.",
|
||||
"extractEmpty": "Er konden geen waarden uit dit document worden geëxtraheerd.",
|
||||
"extractIncomplete": "Een deel van het document kon niet volledig worden gelezen, dus er kunnen waarden ontbreken. Vergelijk de lijst met het document.",
|
||||
"failUnitRequired": "Een numerieke waarde heeft een eenheid nodig voordat ze kan worden opgeslagen.",
|
||||
"failUnitMismatch": "De opgegeven eenheid komt niet overeen met de opgeslagen eenheid voor deze biomarker. Pas de eenheid aan en rond de controle daarna opnieuw af.",
|
||||
"failGeneric": "Deze waarde kon niet worden opgeslagen.",
|
||||
@@ -11867,6 +12093,7 @@
|
||||
"loadMore": "Meer laden",
|
||||
"stateImported": "Staat in HealthLog",
|
||||
"stateDeleted": "Verwijderd in HealthLog",
|
||||
"stateInTrash": "In Recent verwijderd",
|
||||
"selectedCount": "{count} van maximaal {max} geselecteerd",
|
||||
"importAction": "{count} importeren",
|
||||
"importing": "Importeren, één document tegelijk…",
|
||||
@@ -11880,7 +12107,9 @@
|
||||
"importedLinked": "Geïmporteerd en gekoppeld",
|
||||
"duplicate": "Staat al in HealthLog",
|
||||
"duplicateLinked": "Staat al in HealthLog, nu gekoppeld",
|
||||
"deleted": "Verwijderd in HealthLog, ongemoeid gelaten"
|
||||
"deleted": "Verwijderd in HealthLog, ongemoeid gelaten",
|
||||
"restored": "Hersteld uit Recent verwijderd",
|
||||
"restoredLinked": "Hersteld uit Recent verwijderd en gekoppeld"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Documentarchieven zijn niet ingeschakeld op deze server.",
|
||||
@@ -11924,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Dit document verwijderen?",
|
||||
"titleMany": "{count} documenten verwijderen?",
|
||||
"body": "Je kunt dit direct daarna ongedaan maken. Na 30 dagen wordt het definitief verwijderd.",
|
||||
"body": "Verwijderde documenten blijven {days} dagen in Recent verwijderd en worden daarna definitief verwijderd.",
|
||||
"action": "Verwijderen"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Recent verwijderd",
|
||||
"subtitle": "Verwijderde documenten blijven hier {days} dagen en worden daarna definitief verwijderd.",
|
||||
"back": "Documenten",
|
||||
"entryLabel": "Recent verwijderd ({count})",
|
||||
"loadError": "Recent verwijderd kon niet worden geladen.",
|
||||
"emptyTitle": "Niets in Recent verwijderd",
|
||||
"emptyDescription": "Wat je verwijdert, wacht hier {days} dagen voordat het definitief wordt verwijderd.",
|
||||
"count": "Verwijderde documenten ({count})",
|
||||
"restoreAll": "Alles herstellen",
|
||||
"restore": "Herstellen",
|
||||
"purge": "Nu verwijderen",
|
||||
"deletedOn": "Verwijderd op {date}.",
|
||||
"removesIn": "Wordt over {days} dagen definitief verwijderd.",
|
||||
"removesSoon": "Wordt binnen een dag definitief verwijderd.",
|
||||
"restored": "Document hersteld",
|
||||
"openRestored": "Openen",
|
||||
"restoreDuplicate": "Er is al een identiek document opgeslagen, dus deze kopie kan niet worden hersteld.",
|
||||
"restorePartial": "{failed} van {total} konden niet worden hersteld",
|
||||
"purged": "Document definitief verwijderd",
|
||||
"purgeFailed": "Het document kon niet worden verwijderd.",
|
||||
"purgeConfirmTitle": "Dit document definitief verwijderen?",
|
||||
"purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12610,7 +12863,8 @@
|
||||
"entryHint": "Wanneer {name} er klaar voor is om zelf voor dit dossier te zorgen.",
|
||||
"methodSso": "{name} logt via single sign-on in bij het gebruiken van de link.",
|
||||
"methodBoth": "{name} kan een wachtwoord instellen of inloggen via single sign-on."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Alles wat voor {name} is vastgelegd, verdwijnt samen met het profiel: metingen, medicatie, documenten en notities. Het profiel wordt meteen vergrendeld en na {days} dagen gewist. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Opgeslagen in het dossier van {name}"
|
||||
@@ -12665,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} heeft een item in het adresboek hersteld",
|
||||
"illnessDayLogUpsert": "{name} heeft een ziektedag vastgelegd",
|
||||
"medicationUpdate": "{name} heeft een medicijn gewijzigd",
|
||||
"medicationCourseEnd": "{name} heeft een medicijnkuur beëindigd",
|
||||
"medicationDelete": "{name} heeft een medicijn verwijderd",
|
||||
"medicationGlp1Update": "{name} heeft een GLP-1-kuur gewijzigd",
|
||||
"medicationScheduleCreate": "{name} heeft een doseerschema toegevoegd",
|
||||
@@ -12712,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} probeerde een dosis te wijzigen, maar dat werd geweigerd",
|
||||
"rejectedDoseImport": "{name} probeerde doses te importeren, maar dat werd geweigerd",
|
||||
"rejectedTrackedValue": "{name} probeerde een bijgehouden waarde vast te leggen, maar dat werd geweigerd",
|
||||
"rejectedTrackedValueTypeCreate": "{name} probeerde een type bijgehouden waarde toe te voegen, maar dat werd geweigerd",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} probeerde een type bijgehouden waarde te wijzigen, maar dat werd geweigerd",
|
||||
"rejectedMedicationLayout": "{name} probeerde de medicijnlijst anders te ordenen, maar dat werd geweigerd",
|
||||
"deniedDoseImport": "Een dosisimport die {name} had gestart, kon niet in de wachtrij worden gezet",
|
||||
"anamnesisFactCreate": "{name} heeft een gegeven aan de gezondheidsachtergrond toegevoegd",
|
||||
"customMetricCreate": "{name} heeft een type bijgehouden waarde toegevoegd",
|
||||
@@ -13021,7 +13279,8 @@
|
||||
"backgroundScan": "Wordt op de achtergrond gelezen. Met een traag model kan dit een paar minuten duren.",
|
||||
"waitingForWorker": "Wachten op het achtergrondproces. Als dit niet verandert, draait het achtergrondproces van de server mogelijk niet.",
|
||||
"errorWorkerUnavailable": "Het achtergrondproces is op dit moment niet beschikbaar. Probeer het zo opnieuw, of vraag de beheerder van de server om het te controleren.",
|
||||
"errorTimedOut": "Het lezen duurde langer dan je ingestelde AI-responstijd toestaat. Probeer het opnieuw, of verhoog de responstijd in de AI-instellingen."
|
||||
"errorTimedOut": "Het lezen duurde langer dan je ingestelde AI-responstijd toestaat. Probeer het opnieuw, of verhoog de responstijd in de AI-instellingen.",
|
||||
"errorReaderBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nieuw symptoom",
|
||||
|
||||
+339
-147
@@ -175,6 +175,9 @@
|
||||
"failed": "Nie udało się odczytać etykiety. Spróbuj ostrzejszego zdjęcia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie",
|
||||
"window": "analizowano od {from} do {to}, może to wykraczać poza okres raportu",
|
||||
"windowLine": "Analizowano od {from} do {to}. Ten okres może wykraczać poza okres raportu."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} z {total} wyników uzyskano w tłumaczeniu niezwalidowanym naukowo; należy traktować je orientacyjnie."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Nieprawidłowe zaproszenie",
|
||||
"description": "Ten link z zaproszeniem jest niekompletny lub został źle skopiowany. Poproś osobę, która cię zaprosiła, o nowy link.",
|
||||
"toLogin": "Przejdź do logowania"
|
||||
}
|
||||
},
|
||||
"deletionPending": "To konto zostanie usunięte {date} i do tego czasu jest zablokowane. Administrator tego serwera może cofnąć usunięcie przed tą datą.",
|
||||
"deletionPendingNoDate": "To konto czeka na usunięcie i jest zablokowane. Administrator tego serwera może cofnąć usunięcie, dopóki się nie zakończy."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Zrób kopię klucza szyfrowania",
|
||||
"body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.",
|
||||
"action": "Otwórz krok"
|
||||
},
|
||||
"quickAddSymptom": "Zapisz objaw",
|
||||
"todayCardVisible": "Pokaż górną kartę"
|
||||
"todayCardVisible": "Pokaż górną kartę",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Zapisz zestaw odzyskiwania",
|
||||
"body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.",
|
||||
"action": "Otwórz zestaw"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Pomiary",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.",
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.",
|
||||
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
|
||||
"errorReasoningOnly": "Model tylko rozumował i nie napisał odpowiedzi. Spróbuj ponownie albo wybierz inny model w ustawieniach AI.",
|
||||
"errorDemo": "W wersji demo Coach nie odpowiada.",
|
||||
"errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.",
|
||||
"errorNoProviderAction": "Połącz dostawcę AI",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
|
||||
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
|
||||
"noRecoveryWarning": "Nie ma odzyskiwania: jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
|
||||
"detail": "Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone; manifest pliku wskazuje, co zawiera.",
|
||||
"detail": "Przenośna kopia twojej dokumentacji do przechowywania i czytania: wartości w zwykłym JSON, dokumenty tylko jako metadane. To nie jest cotygodniowa kopia konta, którą ten serwer trzyma do przywracania konta, i nie odtworzy konta na innym serwerze. Niektóre rodzaje danych nie są jeszcze uwzględnione; manifest pliku wymienia, co zawiera.",
|
||||
"contents": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Plik JSON",
|
||||
"title": "Pomiary i nastrój (JSON)",
|
||||
"description": "Wklej JSON lub prześlij plik .json z pomiarami i wpisami nastroju.",
|
||||
"pasteLabel": "Wklej JSON",
|
||||
"fileInputLabel": "Wybierz plik JSON do zaimportowania",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Nie można odczytać tego pliku.",
|
||||
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
|
||||
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju",
|
||||
"droppedLinks": "Zaimportowano bez tych tagów, bo to konto nie ma żadnego o tej nazwie: {keys}",
|
||||
"keptLabels": "To konto ma już tagi lub grupy z tymi kluczami pod własną nazwą, więc zostały zachowane: {keys}",
|
||||
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
|
||||
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
|
||||
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju.",
|
||||
"limitsHint": "Do {mb} MB i do {rows} wpisów na listę. Dodaje wpisy do twojej dokumentacji; nie przywraca pełnej kopii zapasowej."
|
||||
},
|
||||
"csv": {
|
||||
"title": "Plik CSV",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
|
||||
"subtitle": "Eksportuj swoje dane albo importuj pomiary i wpisy z innych aplikacji i plików."
|
||||
},
|
||||
"about": {
|
||||
"title": "O tej aplikacji",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "Token pokazujemy tylko raz.",
|
||||
"scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji: nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.",
|
||||
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.",
|
||||
"namePlaceholder": "Nazwa, np. Home Assistant"
|
||||
"namePlaceholder": "Nazwa, np. Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Zapisuj pomiary w Apple Health",
|
||||
"createHint": "Aplikacja HealthLog na Twoim iPhonie doda wtedy pomiary z tego tokenu do Apple Health. Możesz to później zmienić na liście tokenów poniżej.",
|
||||
"toggleAria": "Zapisuj pomiary z {name} w Apple Health",
|
||||
"updateFailed": "Nie udało się zmienić ustawienia Apple Health. Spróbuj ponownie.",
|
||||
"unattributed": "Pomiarów wysłanych, zanim pojawiło się to ustawienie, nie da się przypisać do żadnego tokenu, więc nie trafiają do Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Tokeny treningów",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Usuwa Twoje konto wraz z kluczami dostępu, dziennikiem audytu i sesjami.",
|
||||
"deleteAccountCta": "Usuń konto",
|
||||
"deleteAccountConfirmTitle": "Trwale usunąć konto?",
|
||||
"deleteAccountConfirmDescription": "Twoje konto, wszystkie dane zdrowotne, klucze dostępu, sesje i wpisy audytu zostaną trwale usunięte. Zostaniesz wylogowany natychmiast po tym.",
|
||||
"deleteAccountConfirmTitleGrace": "Usunąć konto?",
|
||||
"deleteAccountConfirmDescription": "Twoje konto, wszystkie dane zdrowotne, klucze dostępu, sesje i wpisy audytu zostaną trwale usunięte. Zostaniesz wylogowany natychmiast po tym. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
|
||||
"deleteAccountSuccess": "Konto usunięte, trwa wylogowywanie.",
|
||||
"deleteAccountFailed": "Nie udało się usunąć konta",
|
||||
"deleteAccountFinal": "Usuń trwale",
|
||||
"deleteAccountFinalGrace": "Usuń konto",
|
||||
"saved": "Zapisano",
|
||||
"savingError": "Błąd podczas zapisywania",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Rozmowy z asystentem: {days} dni",
|
||||
"audit": "Dziennik zabezpieczeń: {days} dni",
|
||||
"delivery": "Dziennik wysyłki powiadomień: {days} dni",
|
||||
"unavailable": "Szczegóły przechowywania są obecnie niedostępne."
|
||||
"unavailable": "Szczegóły przechowywania są obecnie niedostępne.",
|
||||
"accountDeletion": "Usunięcie konta, profilu zarządzanego lub wszystkich danych: kończy się po {days} dniach",
|
||||
"accountDeletionImmediate": "Usunięcie konta, profilu zarządzanego lub wszystkich danych: kończy się od razu"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Usuń lub zresetuj",
|
||||
"description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.",
|
||||
"backupLag": "Usunięcie natychmiast usuwa Twoje dane. Jeśli ten serwer przechowuje zaszyfrowane kopie zapasowe poza serwerem, kopie Twoich danych są usuwane z tego magazynu zaraz potem, a jeśli jest niedostępny, przy następnym nocnym przebiegu.",
|
||||
"openAdvanced": "Usuń lub zresetuj"
|
||||
"openAdvanced": "Usuń lub zresetuj",
|
||||
"immediateNotice": "Usunięcie natychmiast usuwa Twoje dane. Jeśli ten serwer przechowuje zaszyfrowane kopie zapasowe poza serwerem, kopie Twoich danych są usuwane z tego magazynu zaraz potem, a jeśli jest niedostępny, przy następnym nocnym przebiegu. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
|
||||
"graceNotice": "Gdy usuwasz konto, zostaje ono od razu zablokowane i usunięte po {days} dniach, razem z każdą kopią, którą przechowuje ten serwer, także zaszyfrowanymi kopiami poza serwerem. Do tego czasu administrator tego serwera może cofnąć usunięcie. Usunięcie wszystkich danych działa tak samo: kopia dokumentacji jest przechowywana przez {days} dni, więc administrator może ją przywrócić. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Ustawienia prywatności",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Wyłączone",
|
||||
"change": "Zmień w Modułach"
|
||||
},
|
||||
"timezoneUseDevice": "Użyj strefy czasowej tego urządzenia ({zone})"
|
||||
"timezoneUseDevice": "Użyj strefy czasowej tego urządzenia ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Ta czynność usuwa wszystko z Twojej dokumentacji: pomiary, leki, wpisy nastroju, wyniki badań, wpisy cyklu, dokumenty, rozmowy z SI oraz wszystkie połączenia integracji, a wraz z nimi wszystko, co z nich wyliczono, i wszystko, co usunięto wcześniej. Linki udostępnione poradni natychmiast przestają działać. Konto zostaje, razem z danymi logowania i ustawieniami wyświetlania, takimi jak język, strefa czasowa i jednostki. Kopia twojej dokumentacji jest przechowywana przez {days} dni, więc do tego czasu administrator tego serwera może ją przywrócić. Potem usunięcie jest ostateczne.",
|
||||
"deleteAccountCardDetailGrace": "Twoje konto zostaje od razu zablokowane i usunięte po {days} dniach. Do tego czasu administrator tego serwera może cofnąć usunięcie.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Nie można wymusić wylogowania samego siebie",
|
||||
"emptyTitle": "Brak użytkowników w tym widoku",
|
||||
"emptyDescription": "Brak użytkowników pasujących do bieżącego filtra.",
|
||||
"emptyResetFilter": "Pokaż wszystkich użytkowników"
|
||||
"emptyResetFilter": "Pokaż wszystkich użytkowników",
|
||||
"deletionPending": "Usunięcie {date}",
|
||||
"deletionPendingNoDate": "Usunięcie zaplanowane",
|
||||
"undoDeletion": "Cofnij usunięcie",
|
||||
"undoDeletionTitle": "Cofnąć usunięcie {name}?",
|
||||
"undoDeletionBody": "{name} poprosił o usunięcie tego konta; zostanie ono usunięte po upływie okresu karencji. Cofnięcie zachowuje konto i całą jego zawartość bez zmian. {name} może znów się zalogować, ale tokeny API, linki udostępniania i zaufane urządzenia pozostają unieważnione i trzeba je skonfigurować od nowa.",
|
||||
"undoDeletionBodyGuardian": "Osoba opiekująca się profilem usunęła profil zarządzany {name}; zostanie on usunięty po upływie okresu karencji. Cofnięcie zachowuje profil i całą jego zawartość bez zmian, a osoby opiekujące się nim znów mają do niego dostęp. Jego linki udostępniania i tokeny pozostają unieważnione i trzeba je skonfigurować od nowa.",
|
||||
"undoDeletionDone": "Cofnięto usunięcie {name}",
|
||||
"undoDeletionFailed": "Nie udało się cofnąć usunięcia"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Zaproszenia",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Kopie zapasowe",
|
||||
"subtitle": "Cotygodniowe migawki danych dla każdego użytkownika przechowywane w PostgreSQL.",
|
||||
"description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob. Pobieranie odszyfrowuje migawkę dla proszącego administratora; szyfrogram zostaje w bazie danych.",
|
||||
"subtitle": "Kopie kont w bazie danych tego serwera, do przywrócenia jednego konta na tym serwerze.",
|
||||
"description": "Każde konto ma jedną cotygodniową kopię, zastępowaną w każdą niedzielę, a do tego kopie przesłane tutaj i kopię wykonaną przed „Usuń wszystkie dane”. Kopia przywraca istniejące konto na tym serwerze. Nie odtworzy utraconego serwera: do tego potrzebny jest zrzut bazy danych i klucze szyfrowania. Rozmiary dotyczą zaszyfrowanej kopii. Pobieranie odszyfrowuje ją dla administratora, który o nią prosi; szyfrogram zostaje w bazie danych.",
|
||||
"runNow": "Utwórz kopię teraz",
|
||||
"runEnqueued": "Zadanie kopii zapasowej dodano do kolejki",
|
||||
"runFailed": "Nie udało się dodać kopii do kolejki",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Kopie zapasowe wykonują się automatycznie w każdą niedzielę o 03:00. Możesz też uruchomić jedną teraz, aby utworzyć pierwszą migawkę.",
|
||||
"colUser": "Użytkownik",
|
||||
"colType": "Typ",
|
||||
"typeWeeklyAuto": "Cotygodniowo (auto)",
|
||||
"typeWeeklyAuto": "Cotygodniowa kopia konta",
|
||||
"typeManual": "Ręcznie",
|
||||
"colSize": "Rozmiar",
|
||||
"colCreatedAt": "Ostatnia migawka",
|
||||
"colActions": "Akcje",
|
||||
"retentionLabel": "Przechowywanie: {days} dni",
|
||||
"scheduleStaleTitle": "Cotygodniowa kopia zapasowa przestała powstawać",
|
||||
"scheduleStaleDescription": "Najnowsza zaplanowana kopia ma {days} dni. Działające cotygodniowe uruchomienie nigdy nie zostawia kopii starszej niż {threshold} dni.",
|
||||
"scheduleFailedTitle": "Ostatnie zaplanowane uruchomienie kopii zapasowej nie powiodło się",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Pobieranie nie powiodło się",
|
||||
"downloadStarted": "Rozpoczęto pobieranie",
|
||||
"uploadTitle": "Prześlij kopię zapasową",
|
||||
"uploadDescription": "Sprawdź plik kopii zapasowej i zapisz go jako ręczną migawkę. Przywracanie to osobny krok.",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.",
|
||||
"uploadButton": "Prześlij plik",
|
||||
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
|
||||
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Ustawienie konta",
|
||||
"restoreMissingTitle": "Przywracanie odrzucone: plik jest niekompletny",
|
||||
"restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.",
|
||||
"restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.",
|
||||
"restoreMissingRemedy": "Użyj jednej z kopii konta z tego serwera lub kopii spoza serwera albo wyeksportuj konto ponownie i prześlij nowy plik. Sekcja, którą manifest deklaruje jako pominiętą, to co innego i przywraca się normalnie.",
|
||||
"restoreMissingDocuments": "Dokumenty",
|
||||
"restoreMissingWorkouts": "Treningi",
|
||||
"restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Dla tego konta trwa już przywracanie. Poczekaj, aż się skończy.",
|
||||
"restoreJobQueuedTitle": "Przywracanie {username} czeka na start",
|
||||
"restoreJobQueuedNote": "Zacznie się, gdy tylko proces w tle je przejmie.",
|
||||
"restoreJobWaitingBackupNote": "Czeka na kopię zapasową, która właśnie działa na serwerze. Przywracanie nigdy nie działa równolegle z kopią i zaczyna się mniej więcej minutę po jej zakończeniu.",
|
||||
"restoreJobWaitingBusyNote": "Czeka na zakończenie dużego zadania na serwerze, na przykład importu albo nocnego przebiegu. Zacznie się mniej więcej minutę później.",
|
||||
"restoreJobQueuedSince": "W kolejce od {when}.",
|
||||
"restoreJobRunningTitle": "Przywracanie {username}",
|
||||
"restoreJobRunningNote": "Konto pokazuje dotychczasowe dane, dopóki przywracanie się nie zakończy. Możesz opuścić tę stronę; przywracanie trwa dalej na serwerze.",
|
||||
"restoreJobResumed": "Uruchomione ponownie po restarcie serwera.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "Plik w kopii należy do innego konta niż wiersz kopii.",
|
||||
"restoreFailureOwnerMissing": "Konto, do którego należy ta kopia, już nie istnieje.",
|
||||
"restoreFailureSectionMissing": "Plik jest niekompletny: jego manifest wymienia sekcję, której nie zawiera.",
|
||||
"restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak eksport przenośny. Użyj kopii do odtwarzania po awarii.",
|
||||
"restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak przenośny eksport. Użyj zamiast tego jednej z kopii konta z tego serwera lub kopii spoza serwera.",
|
||||
"restoreFailureTimeBudget": "Ta kopia jest za duża, by przywrócić ją w limicie czasu procesu w tle.",
|
||||
"restoreFailureTransaction": "Baza danych odrzuciła przywracanie w trakcie, więc wszystko wycofano. Dziennik serwera podaje błąd.",
|
||||
"restoreFailureCauseTimeout": "Przywracanie trwało dłużej, niż pozwala baza danych, więc wszystko wycofano. Jeśli serwer bazy był obciążony, uruchom je ponownie, gdy będzie spokojniej. Dziennik serwera podaje krok.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)",
|
||||
"restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)",
|
||||
"restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)",
|
||||
"typePreWipe": "Przed usunięciem wszystkich danych",
|
||||
"retentionNote": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Kopia wykonana przed „Usuń wszystkie dane” jest usuwana po {days} dniach. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte.",
|
||||
"retentionNoteNoGrace": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte.",
|
||||
"database": {
|
||||
"title": "Zrzut bazy danych",
|
||||
"lastGood": "Ostatni poprawny zrzut {when}, {size}, {where}.",
|
||||
"whereBoth": "przechowywany na tym serwerze i poza nim",
|
||||
"whereLocal": "przechowywany na tym serwerze",
|
||||
"whereOffhost": "tylko poza serwerem",
|
||||
"none": "Nie ma jeszcze zrzutu bazy danych. Pierwszy powstanie tej nocy.",
|
||||
"failedTitle": "Ostatni zrzut bazy danych się nie powiódł",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Zrzuty bazy danych są wyłączone (DB_BACKUP_ENABLED=false). Zrób kopię bazy danych w inny sposób.",
|
||||
"noDirectoryOffhost": "Ten serwer nie może zapisywać w {dir}, więc zrzuty trafiają tylko poza serwer i żaden nie zostaje tutaj. Podłącz wolumin kopii.",
|
||||
"noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii.",
|
||||
"retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych zrzutów nocnych, a zrzut sprzed ostatniej aktualizacji zostaje do następnej.",
|
||||
"retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.",
|
||||
"retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.",
|
||||
"notPersistentOffhost": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), więc zapisany tam zrzut przepadnie przy następnej aktualizacji. Zrzuty trafiają tylko poza serwer: żaden nie zostaje tutaj i żaden nie powstaje przed migracją. Podłącz wolumin kopii.",
|
||||
"notPersistent": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), a zewnętrzny bucket nie jest ustawiony, więc żaden zrzut bazy nie jest nigdzie przechowywany. Podłącz wolumin kopii.",
|
||||
"offhostFailedTitle": "Najnowszy zrzut bazy danych nie trafił do zewnętrznego bucketu",
|
||||
"offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.",
|
||||
"offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych."
|
||||
},
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Ukryj tabelę",
|
||||
"columnsShowAll": "Pokaż wszystkie kolumny ({count})",
|
||||
"columnsShowWithRows": "Tylko kolumny z wierszami",
|
||||
"columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy."
|
||||
"columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy.",
|
||||
"backupsDumpLocalLine": "Klucz {key}: {count} zrzutów bazy danych na tym serwerze zapisano pod nim; jest potrzebny, dopóki retencja ich nie usunie.",
|
||||
"backupsDumpOffhostLine": "Klucz {key}: najnowszy zrzut bazy danych zapisany pod nim trafił do bucketu {when}; potrzebny tam do {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Klucz {key}: najnowszy zrzut bazy danych zapisany pod nim trafił do bucketu {when}. Nie wiadomo, jak długo bucket przechowuje database/, więc zachowaj klucz, dopóki nie masz pewności, że tego zrzutu już nie ma."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Konserwacja tabeli pomiarów",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Nie udało się wyświetlić tej części strony",
|
||||
"description": "Reszta strony działa; spróbuj ponownie lub odśwież, jeśli błąd się powtarza."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Zrób kopię klucza szyfrowania",
|
||||
"description": "Bez kopii tego klucza Twoich zaszyfrowanych danych i kopii zapasowych z tego serwera nie da się już odczytać.",
|
||||
"loading": "Wczytywanie stanu kopii klucza…",
|
||||
"loadError": "Nie udało się wczytać stanu kopii klucza.",
|
||||
"badgeDue": "Do zrobienia",
|
||||
"badgeDone": "Zapisany",
|
||||
"keyId": "ID aktywnego klucza",
|
||||
"fingerprint": "Odcisk",
|
||||
"whatIsLost": "Przechowuj kopię klucza poza tym serwerem, na przykład w menedżerze haseł. Jeśli serwer zostanie przeinstalowany z innym kluczem, HealthLog nie wznowi działania, dopóki oryginalny klucz nie wróci.",
|
||||
"fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.",
|
||||
"checkLabel": "Sprawdź moją kopię",
|
||||
"checkButton": "Sprawdź",
|
||||
"checkNote": "Wklejony klucz jest porównywany z aktywnym kluczem, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.",
|
||||
"checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.",
|
||||
"checkMatches": "Twoja kopia pasuje do aktywnego klucza.",
|
||||
"checkNoMatch": "Twoja kopia nie pasuje do aktywnego klucza. Sprawdź, czy skopiowano całą wartość.",
|
||||
"checkFailed": "Sprawdzenie nie powiodło się.",
|
||||
"confirmButton": "Mam kopię tego klucza",
|
||||
"confirmed": "Kopia klucza potwierdzona.",
|
||||
"confirmFailed": "Nie udało się zapisać potwierdzenia.",
|
||||
"stale": "Klucz serwera zmienił się od wczytania tej strony. Sprawdź nowe ID i odcisk, a potem potwierdź ponownie.",
|
||||
"confirmedAt": "Potwierdzono {date}.",
|
||||
"confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.",
|
||||
"dueAgain": "Potwierdzono kopię klucza {keyId}, ale serwer używa teraz innego klucza. Zrób kopię obecnego.",
|
||||
"tileLabel": "Kopia klucza szyfrowania",
|
||||
"tileDue": "Niepotwierdzona",
|
||||
"tileDone": "Potwierdzona",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog, kliknij Edytuj i skopiuj ENCRYPTION_KEY ze zmiennych środowiskowych.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY z jego zmiennych środowiskowych."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog nie może otworzyć swoich danych",
|
||||
"body": "Klucz szyfrowania, z którym uruchomiono ten serwer, nie jest kluczem, którym zapisano jego bazę danych. Aby chronić Twoje dane, HealthLog niczego nie udostępnia, dopóki to nie zostanie naprawione.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.",
|
||||
"gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)",
|
||||
"gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Zestaw odzyskiwania",
|
||||
"description": "Każdy klucz potrzebny do przywrócenia tego serwera, z ID i odciskiem.",
|
||||
"intro": "Przechowuj kopię każdego z poniższych kluczy poza tym serwerem, na przykład w menedżerze haseł. Bez nich nie da się ponownie odczytać ani zrzutu bazy danych, ani kopii zapasowej, a HealthLog nie uruchomi się, jeśli brakuje potrzebnego mu klucza.",
|
||||
"loading": "Wczytywanie zestawu odzyskiwania…",
|
||||
"loadError": "Nie udało się wczytać zestawu odzyskiwania.",
|
||||
"badgeDue": "Do zrobienia",
|
||||
"badgeDone": "Zapisany",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Odcisk",
|
||||
"noFingerprint": "Brak na tym serwerze",
|
||||
"kind": {
|
||||
"application": "Klucz aplikacji {id}",
|
||||
"backup": "Klucz kopii poza hostem",
|
||||
"backupPrevious": "Poprzedni klucz kopii poza hostem",
|
||||
"token": "Klucz tokenów"
|
||||
},
|
||||
"need": {
|
||||
"active": "Szyfruje nowe dane.",
|
||||
"configured": "Znajduje się na tym serwerze.",
|
||||
"storedCopies": "Potrzebny zapisanym kopiom: {count}.",
|
||||
"offhostUntil": "Potrzebny kopiom poza hostem do {date}.",
|
||||
"offhostUnknown": "Potrzebny kopiom poza hostem, dopóki bucket je przechowuje.",
|
||||
"backupActive": "Pieczętuje każdą nową kopię poza hostem.",
|
||||
"backupPrevious": "Otwiera kopie poza hostem zapisane przed ostatnią zmianą klucza.",
|
||||
"token": "Podpisuje sesje, tokeny i linki udostępniania. Bez niego wszyscy zostaną wylogowani, ale żadne dane nie przepadną.",
|
||||
"localDumps": "Potrzebny zrzutom bazy danych na tym serwerze: {count}.",
|
||||
"offhostDumpsUntil": "Potrzebny zrzutom bazy danych poza hostem do {date}.",
|
||||
"offhostDumpsUnknown": "Potrzebny zrzutom bazy danych poza hostem, dopóki bucket je przechowuje."
|
||||
},
|
||||
"source": "Ustawiony w {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Zapisany",
|
||||
"new": "Jeszcze niezapisany",
|
||||
"notConfigured": "Brak na tym serwerze",
|
||||
"unreadable": "Nieprawidłowy klucz"
|
||||
},
|
||||
"notConfiguredHint": "Kopia zapasowa nadal potrzebuje tego klucza, a ten serwer go nie ma. Te kopie zapasowe może odczytać tylko kopia klucza, którą przechowujesz gdzie indziej. Potwierdź zestaw, gdy będziesz mieć tę kopię.",
|
||||
"unreadableHint": "Zmienna jest ustawiona, ale nie zawiera prawidłowego klucza. Popraw ją, zanim zaczniesz polegać na kopiach poza hostem.",
|
||||
"fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.",
|
||||
"whereTitle": "Gdzie ustawione są klucze",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Klucze znajdują się w pliku .env obok docker-compose.yml: ENCRYPTION_KEY lub ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY oraz, przy kopiach poza hostem, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edit. Klucze są w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY w ustawieniach środowiska.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jej Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jego zmiennych środowiskowych."
|
||||
},
|
||||
"restoreTitle": "Przywracanie tego serwera",
|
||||
"restoreBody": "Zainstaluj HealthLog z tymi samymi kluczami, przywróć najnowszy zrzut bazy danych do pustej bazy, a potem uruchom aplikację. Przewodnik prowadzi przez każdy krok.",
|
||||
"restoreLink": "Przewodnik odzyskiwania po awarii",
|
||||
"checkLabel": "Sprawdź moją kopię",
|
||||
"checkKeyLabel": "Klucz do sprawdzenia",
|
||||
"checkValueLabel": "Twoja kopia klucza",
|
||||
"checkButton": "Sprawdź",
|
||||
"checkNote": "Wklejony klucz jest porównywany z kluczem na tym serwerze, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.",
|
||||
"checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.",
|
||||
"checkMatches": "Twoja kopia pasuje do tego klucza.",
|
||||
"checkNoMatch": "Twoja kopia nie pasuje do tego klucza. Sprawdź, czy skopiowano całą wartość.",
|
||||
"checkFailed": "Sprawdzenie nie powiodło się.",
|
||||
"confirmButton": "Mam kopię każdego klucza",
|
||||
"confirmed": "Zestaw odzyskiwania potwierdzony.",
|
||||
"confirmFailed": "Nie udało się zapisać potwierdzenia.",
|
||||
"stale": "Zestaw zmienił się od wczytania tej strony. Sprawdź klucze, które teraz wymienia, a potem potwierdź ponownie.",
|
||||
"confirmedAt": "Potwierdzono {date}.",
|
||||
"confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.",
|
||||
"dueAgain": "Zestaw potwierdzono {date}. Zawiera teraz także {keys}, czego to potwierdzenie nie obejmowało. Zapisz kopię każdego i potwierdź ponownie."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Gotowość do odzyskiwania",
|
||||
"description": "Czy ten serwer dałoby się odtworzyć po utracie, oceniane na podstawie każdej kopii zapasowej, którą tworzy.",
|
||||
"loading": "Sprawdzanie kopii zapasowych…",
|
||||
"loadError": "Nie udało się wczytać gotowości do odzyskiwania.",
|
||||
"state": {
|
||||
"ok": "Gotowe",
|
||||
"attention": "Wymaga uwagi",
|
||||
"failing": "Zagrożone"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Zrzut bazy danych",
|
||||
"weekly_copy": "Cotygodniowe kopie kont",
|
||||
"offhost_copies": "Kopie poza hostem",
|
||||
"restore_drill": "Próba przywracania",
|
||||
"offhost_setup": "Kopia poza hostem",
|
||||
"recovery_kit": "Zestaw odzyskiwania",
|
||||
"dump_retention": "Zrzuty w buckecie"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Najnowszy zrzut: {date}",
|
||||
"weekly_copy": "Najstarsza kopia konta: {date}",
|
||||
"offhost_copies": "Najstarsza kopia poza hostem: {date}",
|
||||
"restore_drill": "Ostatnia próba: {date}",
|
||||
"recovery_kit": "Potwierdzono: {date}"
|
||||
},
|
||||
"dueBy": "Termin: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Otwórz kopie zapasowe",
|
||||
"weekly_copy": "Otwórz kopie zapasowe",
|
||||
"offhost_copies": "Otwórz kopie zapasowe",
|
||||
"restore_drill": "Otwórz kopie zapasowe",
|
||||
"offhost_setup": "Otwórz kopie zapasowe",
|
||||
"recovery_kit": "Otwórz zestaw",
|
||||
"dump_retention": "Otwórz kopie zapasowe"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Ta wersja nie planuje zrzutu bazy danych.",
|
||||
"disabled": "Zaplanowany zrzut bazy danych jest wyłączony, więc żadna kopia nie odtworzy tego serwera. Aby go włączyć, usuń DB_BACKUP_ENABLED lub ustaw na true i uruchom HealthLog ponownie.",
|
||||
"unreadable": "Nie udało się odczytać stanu zrzutu bazy danych.",
|
||||
"never_succeeded": "Żaden zrzut bazy danych jeszcze się nie udał, a ostatnia próba zakończyła się błędem.",
|
||||
"never_ran": "Od włączenia zrzutów minęło {hours} h, a nie zapisano żadnego zrzutu bazy danych.",
|
||||
"first_pending": "Pierwszy zrzut bazy danych jeszcze się nie odbył.",
|
||||
"stale": "Najnowszy zrzut bazy danych ma {hours} h.",
|
||||
"last_failed": "Ostatni zrzut bazy danych nie powiódł się. Poprzedni jest jeszcze aktualny.",
|
||||
"fresh": "Najnowszy zrzut bazy danych jest aktualny.",
|
||||
"nowhere_to_write": "Katalog zrzutów {directory} nie jest zapisywalny i nie skonfigurowano zewnętrznego bucketu, więc żaden zrzut nie może zostać zachowany.",
|
||||
"not_persistent": "Katalog zrzutów {directory} nie leży na trwałym nośniku, a zewnętrzny bucket nie jest ustawiony, więc każdy zrzut przepadnie przy następnej aktualizacji. Podłącz wolumin kopii.",
|
||||
"fresh_offhost_only": "Najnowszy zrzut bazy jest aktualny i leży w zewnętrznym buckecie. Na tym serwerze żaden nie zostaje, bo {directory} nie leży na trwałym nośniku, i żaden nie powstaje przed migracją.",
|
||||
"offhost_failed": "Najnowszy zrzut bazy danych jest przechowywany na tym serwerze, ale jego przesłanie do zewnętrznego bucketu się nie powiodło.",
|
||||
"offhost_stale": "Od {hours} godzin do zewnętrznego bucketu nie trafił żaden zrzut bazy danych: przesyłanie wciąż się nie udaje. Jeśli ten serwer przepadnie, nie ma aktualnego zrzutu, z którego można go odbudować.",
|
||||
"offhost_never": "Żaden zrzut bazy danych nigdy nie trafił do zewnętrznego bucketu: przesyłanie się nie udaje. Jeśli ten serwer przepadnie, nie ma zrzutu, z którego można go odbudować."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Nie ma jeszcze kont do skopiowania.",
|
||||
"stale": "Najstarsza cotygodniowa kopia ma {days} dni. Zaległe konta: {accounts}.",
|
||||
"never": "Jedno z kont od {days} dni nie ma cotygodniowej kopii. Zaległe konta: {accounts}.",
|
||||
"first_pending": "Pierwsza cotygodniowa kopia powstaje wcześnie rano w niedzielę.",
|
||||
"fresh": "Każde konto ma kopię z ostatniego tygodnia."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Nie skonfigurowano bucketu poza hostem.",
|
||||
"stale": "Kopie poza hostem są opóźnione o ponad dwie noce. Zaległe konta: {accounts}.",
|
||||
"first_pending": "Nocny przebieg kopii poza hostem nie objął jeszcze żadnego konta.",
|
||||
"fresh": "Każde konto ma aktualną kopię w buckecie poza hostem."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Próba odczytuje bucket poza hostem, który nie jest skonfigurowany.",
|
||||
"not_run_yet": "Comiesięczna próba przywracania jeszcze się nie odbyła.",
|
||||
"failed": "Ostatnia próba przywracania nie powiodła się: nie udało się odczytać {failed} z {checked} kopii.",
|
||||
"failed_run": "Ostatnia próba przywracania nie dobiegła końca.",
|
||||
"passed": "Ostatnia próba przywracania odczytała kopie: {checked}.",
|
||||
"nothing_checked": "Ostatnia próba przywracania zatrzymała się na limicie czasu, zanim odczytała choć jedną kopię, więc niczego nie potwierdziła."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopie opuszczają ten serwer każdej nocy.",
|
||||
"no_admin": "Nie ma jeszcze konta administratora.",
|
||||
"not_configured": "Nie skonfigurowano bucketu poza hostem, więc każda kopia jest tylko na tym serwerze.",
|
||||
"not_configured_grace": "Nie skonfigurowano jeszcze bucketu poza hostem, więc każda kopia jest tylko na tym serwerze."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Istnieje kopia każdego klucza potrzebnego do przywrócenia.",
|
||||
"out_of_date": "Zestaw odzyskiwania zawiera teraz także {keys}, czego ostatnie potwierdzenie nie obejmowało. Nic się nie zepsuło: zapisz kopię i potwierdź zestaw ponownie.",
|
||||
"no_admin": "Nie ma jeszcze konta administratora.",
|
||||
"not_confirmed": "Nikt nie potwierdził kopii kluczy potrzebnych do przywrócenia: {keys}.",
|
||||
"not_confirmed_grace": "Zapisz kopię kluczy potrzebnych do przywrócenia: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Zewnętrzny bucket nie jest ustawiony.",
|
||||
"no_offhost_dumps": "Żaden zrzut bazy nie trafia do bucketu.",
|
||||
"missing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.",
|
||||
"configured": "Bucket usuwa zrzuty po {days} dniach.",
|
||||
"unknown": "Bucket nie podaje, czy jakaś reguła usuwa stare zrzuty. Sprawdź, czy reguła cyklu życia obejmuje database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Zrzut bazy danych znów jest aktualny.",
|
||||
"weekly_copy": "Każde konto znów ma aktualną cotygodniową kopię.",
|
||||
"offhost_copies": "Każde konto znów ma aktualną kopię poza hostem.",
|
||||
"restore_drill": "Próba przywracania znów się powiodła.",
|
||||
"offhost_setup": "Kopie poza hostem są już skonfigurowane.",
|
||||
"recovery_kit": "Zestaw odzyskiwania jest znów potwierdzony.",
|
||||
"dump_retention": "Reguła cyklu życia w buckecie obejmuje teraz zrzuty bazy."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane",
|
||||
"offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez zapytanie online do {host}, więc każdy adres IP logowania opuszcza ten serwer. Aby rozwiązywać je lokalnie, pobierz bazy GeoLite2-City i GeoLite2-ASN na bezpłatnym koncie MaxMind, podłącz katalog do kontenera i ustaw na niego GEOLITE2_DIR. Kroki: {docsUrl}",
|
||||
"tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}",
|
||||
"tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Nie udało się usunąć konta: {username}",
|
||||
"accountErasureRefusedBody": "Okres usuwania konta {username} minął, ale to konto jest ostatnią aktywną osobą opiekującą się profilem zarządzanym, więc nie zostało usunięte. Pozostaje zablokowane. Dodaj do profilu kolejną osobę opiekującą się, usuń profil albo cofnij usunięcie w Administracja, Użytkownicy.",
|
||||
"accountErasureFailedBody": "Okres usuwania konta {username} minął, ale usunięcie konta się nie powiodło. Konto pozostaje zablokowane, a próba jest ponawiana co godzinę, po innych kontach do usunięcia. Błąd widać na karcie nieudanych zadań w Administracja."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Przypomnienia o badaniach kontrolnych",
|
||||
"description": "Włączaj lub wyłączaj pojedyncze przypomnienia o badaniach."
|
||||
},
|
||||
"eventBackupHealth": "Stan kopii zapasowych",
|
||||
"eventBackupHealthDesc": "Gdy kopia zapasowa przestaje powstawać, zostaje w tyle lub wraca do normy. Wysyłane tylko do administratorów.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Kopie zapasowe wymagają uwagi",
|
||||
"titleFailing": "Kopie zapasowe są zagrożone",
|
||||
"titleReminder": "Kopie zapasowe są nadal zagrożone",
|
||||
"titleRecovered": "Kopie zapasowe znów działają normalnie"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -10947,25 +11162,6 @@
|
||||
"who5": "Test samopoczucia, 5 pytań",
|
||||
"sci": "Test snu, 8 pytań"
|
||||
},
|
||||
"options": {
|
||||
"0": "Wcale nie dokuczały",
|
||||
"1": "Kilka dni",
|
||||
"2": "Więcej niż połowę dni",
|
||||
"3": "Niemal codziennie"
|
||||
},
|
||||
"gad7Options": {
|
||||
"0": "Wcale nie dokuczały",
|
||||
"1": "Kilka dni",
|
||||
"2": "Więcej niż połowę dni",
|
||||
"3": "Niemal codziennie"
|
||||
},
|
||||
"functionalTitle": "Jeżeli zaznaczył/-a Pan/Pani którekolwiek z problemów, jak bardzo utrudniły one Panu/Pani wykonywanie pracy, zajmowanie się domem lub relacje z innymi ludźmi?",
|
||||
"functional": {
|
||||
"0": "W ogóle nie utrudniły",
|
||||
"1": "Trochę utrudniły",
|
||||
"2": "Bardzo utrudniły",
|
||||
"3": "Niezmiernie utrudniły"
|
||||
},
|
||||
"functionalOptionalHint": "Opcjonalne: możesz wysłać bez odpowiedzi na to pytanie.",
|
||||
"result": {
|
||||
"title": "Twój wynik",
|
||||
@@ -11015,35 +11211,6 @@
|
||||
"ifDanger": "Jeśli grozi Ci bezpośrednie niebezpieczeństwo, zadzwoń teraz pod {emergency}.",
|
||||
"resourcesTitle": "Osoby, które mogą pomóc"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności",
|
||||
"2": "Uczucie smutku, przygnębienia lub beznadziejności",
|
||||
"3": "Kłopoty z zaśnięciem lub przerywany sen, albo zbyt długi sen",
|
||||
"4": "Uczucie zmęczenia lub brak energii",
|
||||
"5": "Brak apetytu lub przejadanie się",
|
||||
"6": "Poczucie niezadowolenia z siebie — lub uczucie, że jest się do niczego, albo że zawiódł/zawiodła Pan/Pani siebie lub rodzinę",
|
||||
"7": "Problemy ze skupieniem się na przykład przy czytaniu gazety lub oglądaniu telewizji",
|
||||
"8": "Poruszanie się lub mówienie tak wolno, że inni mogliby to zauważyć? Albo wręcz przeciwnie — niemożność usiedzenia w miejscu lub podenerwowanie powodujące ruchliwość znacznie większą niż zwykle",
|
||||
"9": "Myśli, że lepiej byłoby umrzeć, albo chęć zrobienia sobie jakiejś krzywdy"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)",
|
||||
"2": "Nie mógł(a) Pan(i) przestać się martwić albo zapanować nad tym",
|
||||
"3": "Za bardzo się Pan(i) martwił(a) różnymi rzeczami",
|
||||
"4": "Miał(a) Pan(i) trudności z relaksowaniem się",
|
||||
"5": "Był(a) Pan(i) tak niespokojny(a), że nie mógł(a) usiedzieć na miejscu",
|
||||
"6": "Łatwo stawał(a) się Pan(i) rozdrażniony(a) lub poirytowany(a)",
|
||||
"7": "Obawiał(a) się Pan(i), tak jakby miało się stać coś strasznego"
|
||||
},
|
||||
"who5": {
|
||||
"1": "Czułam się wesoła/Czułem się wesoły i w dobrym nastroju",
|
||||
"2": "Czułam się spokojna i odprężona/Czułem się spokojny i odprężony",
|
||||
"3": "Czułam się aktywna i energiczna/Czułem się aktywny i energiczny",
|
||||
"4": "Budziłam się z uczuciem świeżości i wypoczęta/Budziłem się z uczuciem świeżości i wypoczęty",
|
||||
"5": "Moje życie codzienne było wypełnione interesującymi mnie sprawami"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (całodobowo, bezpłatnie i poufnie)"
|
||||
@@ -11102,26 +11269,6 @@
|
||||
"who5": "Wynik samopoczucia 50 lub niższy może być wart bliższego spojrzenia. Jeśli chcesz, PHQ-9 na tej stronie przygląda się nastrojowi dokładniej. To życzliwa sugestia, a nie diagnoza.",
|
||||
"sci": "W badaniu tego kwestionariusza wyniki 16 lub niższe wiązały się z prawdopodobną bezsennością. Jeśli zły sen nadal Ci doskwiera, rozmowa ze specjalistą może pomóc. To nie jest diagnoza."
|
||||
},
|
||||
"validatedInEnglishNote": "Ten kwestionariusz jest zwalidowany w języku angielskim; pytania są wyświetlane w angielskim oryginale.",
|
||||
"who5Options": {
|
||||
"0": "Nigdy",
|
||||
"1": "Od czasu do czasu",
|
||||
"2": "Mniej niż połowę czasu",
|
||||
"3": "Więcej niż połowę czasu",
|
||||
"4": "Prawie cały czas",
|
||||
"5": "Cały czas"
|
||||
},
|
||||
"stems": {
|
||||
"phq9": {
|
||||
"period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu/Pani następujące problemy?"
|
||||
},
|
||||
"gad7": {
|
||||
"period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu(i) następujące problemy?"
|
||||
},
|
||||
"who5": {
|
||||
"period": "W ciągu ostatnich dwóch tygodni"
|
||||
}
|
||||
},
|
||||
"openDetail": "Pokaż szczegóły",
|
||||
"historyLoadError": "Nie udało się wczytać historii testów",
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.",
|
||||
@@ -11171,6 +11318,13 @@
|
||||
"belowThreshold": "Twój sen w ostatnim miesiącu mieścił się w zakresie, w którym badanie stojące za tym kwestionariuszem uznało bezsenność za prawdopodobną.",
|
||||
"aboveThreshold": "Twój sen w ostatnim miesiącu był poza zakresem, który to badanie wiąże z bezsennością."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "To tłumaczenie nie zostało zwalidowane naukowo; traktuj wynik jako orientacyjny.",
|
||||
"showOriginal": "Pokaż oryginalny tekst angielski",
|
||||
"showTranslation": "Pokaż tłumaczenie",
|
||||
"resultUnvalidated": "Odpowiedzi udzielone w tłumaczeniu, które nie zostało zwalidowane naukowo; traktuj wynik jako orientacyjny.",
|
||||
"historyUnvalidated": "Odpowiedzi w tłumaczeniu niezwalidowanym naukowo"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11698,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Już zapisano. Istniejący dokument został wyróżniony.",
|
||||
"deleted": "Dokument usunięty",
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu."
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu.",
|
||||
"deletedHint": "Usunięte dokumenty zostają w sekcji Ostatnio usunięte przez {days} dni."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Wczytywanie…",
|
||||
@@ -11743,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tego dokumentu.",
|
||||
@@ -11815,7 +11971,7 @@
|
||||
"dismiss": "Odrzuć",
|
||||
"empty": "Nie udało się nic odczytać z tego dokumentu.",
|
||||
"unavailableBody": "Skonfiguruj dostawcę AI, aby zaproponować tytuł, typ i datę.",
|
||||
"unavailableLocalOcr": "Włącz lokalne OCR w ustawieniach AI, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableLocalOcr": "Włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableAction": "Otwórz ustawienia AI",
|
||||
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
|
||||
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
|
||||
@@ -11827,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
|
||||
"errorOutputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj modelu, który pozwala na dłuższe odpowiedzi.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania.",
|
||||
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
|
||||
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik.",
|
||||
"errorConsent": "Odczyt dokumentów przez AI wymaga najpierw Twojej zgody.",
|
||||
"errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny."
|
||||
"errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny.",
|
||||
"unavailableLocalOcrAction": "Otwórz ustawienia badań"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Podsumuj",
|
||||
@@ -11887,6 +12045,7 @@
|
||||
"extracting": "Odczytywanie…",
|
||||
"extractHint": "Korzysta z tekstu już odczytanego z tego dokumentu. Odczytane wartości czekają na Twoją weryfikację; nic nie zapisuje się automatycznie.",
|
||||
"extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
|
||||
"extractIncomplete": "Części dokumentu nie udało się odczytać w całości, więc może brakować niektórych wartości. Porównaj listę z dokumentem.",
|
||||
"failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
|
||||
"failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
|
||||
"failGeneric": "Nie udało się zapisać tej wartości.",
|
||||
@@ -11934,6 +12093,7 @@
|
||||
"loadMore": "Wczytaj więcej",
|
||||
"stateImported": "W HealthLog",
|
||||
"stateDeleted": "Usunięty w HealthLog",
|
||||
"stateInTrash": "W sekcji Ostatnio usunięte",
|
||||
"selectedCount": "Wybrano {count} z maksymalnie {max}",
|
||||
"importAction": "Importuj ({count})",
|
||||
"importing": "Importowanie, dokument po dokumencie…",
|
||||
@@ -11947,7 +12107,9 @@
|
||||
"importedLinked": "Zaimportowano i powiązano",
|
||||
"duplicate": "Już jest w HealthLog",
|
||||
"duplicateLinked": "Już jest w HealthLog, teraz powiązany",
|
||||
"deleted": "Usunięty w HealthLog, pozostaje usunięty"
|
||||
"deleted": "Usunięty w HealthLog, pozostaje usunięty",
|
||||
"restored": "Przywrócono z sekcji Ostatnio usunięte",
|
||||
"restoredLinked": "Przywrócono z sekcji Ostatnio usunięte i powiązano"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Archiwa dokumentów nie są włączone na tym serwerze.",
|
||||
@@ -11991,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Usunąć ten dokument?",
|
||||
"titleMany": "Usunąć dokumenty ({count})?",
|
||||
"body": "Zaraz potem możesz to cofnąć. Po 30 dniach zostanie usunięte na stałe.",
|
||||
"body": "Usunięte dokumenty zostają w sekcji Ostatnio usunięte przez {days} dni, a potem są usuwane na stałe.",
|
||||
"action": "Usuń"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Ostatnio usunięte",
|
||||
"subtitle": "Usunięte dokumenty zostają tu przez {days} dni, a potem są usuwane na stałe.",
|
||||
"back": "Dokumenty",
|
||||
"entryLabel": "Ostatnio usunięte ({count})",
|
||||
"loadError": "Nie udało się wczytać sekcji Ostatnio usunięte.",
|
||||
"emptyTitle": "Brak elementów w sekcji Ostatnio usunięte",
|
||||
"emptyDescription": "To, co usuniesz, czeka tu {days} dni, zanim zostanie usunięte na stałe.",
|
||||
"count": "Usunięte dokumenty ({count})",
|
||||
"restoreAll": "Przywróć wszystkie",
|
||||
"restore": "Przywróć",
|
||||
"purge": "Usuń teraz",
|
||||
"deletedOn": "Usunięto {date}.",
|
||||
"removesIn": "Zostanie usunięty na stałe za {days} dni.",
|
||||
"removesSoon": "Zostanie usunięty na stałe w ciągu doby.",
|
||||
"restored": "Przywrócono dokument",
|
||||
"openRestored": "Otwórz",
|
||||
"restoreDuplicate": "Identyczny dokument jest już zapisany, więc tej kopii nie można przywrócić.",
|
||||
"restorePartial": "Nie udało się przywrócić {failed} z {total}",
|
||||
"purged": "Dokument usunięty na stałe",
|
||||
"purgeFailed": "Nie udało się usunąć dokumentu.",
|
||||
"purgeConfirmTitle": "Usunąć ten dokument na stałe?",
|
||||
"purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12677,7 +12863,8 @@
|
||||
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę.",
|
||||
"methodSso": "{name} loguje się przez logowanie jednokrotne (SSO), gdy użyje linku.",
|
||||
"methodBoth": "{name} może ustawić hasło albo zalogować się przez logowanie jednokrotne (SSO)."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Wszystko, co zapisano dla {name}, znika razem z profilem: pomiary, leki, dokumenty i notatki. Profil zostaje od razu zablokowany i usunięty po {days} dniach. Do tego czasu administrator tego serwera może cofnąć usunięcie."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}"
|
||||
@@ -12732,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} przywrócił wpis w książce adresowej",
|
||||
"illnessDayLogUpsert": "{name} zapisał dzień choroby",
|
||||
"medicationUpdate": "{name} zmienił lek",
|
||||
"medicationCourseEnd": "{name} zakończył kurację",
|
||||
"medicationDelete": "{name} usunął lek",
|
||||
"medicationGlp1Update": "{name} zmienił terapię GLP-1",
|
||||
"medicationScheduleCreate": "{name} dodał schemat dawkowania",
|
||||
@@ -12779,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony",
|
||||
"rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony",
|
||||
"rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony",
|
||||
"rejectedTrackedValueTypeCreate": "{name} próbował dodać własny typ pomiaru i został odrzucony",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} próbował zmienić własny typ pomiaru i został odrzucony",
|
||||
"rejectedMedicationLayout": "{name} próbował zmienić kolejność listy leków i został odrzucony",
|
||||
"deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany",
|
||||
"anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego",
|
||||
"customMetricCreate": "{name} dodał własny typ pomiaru",
|
||||
@@ -13088,7 +13279,8 @@
|
||||
"backgroundScan": "Odczyt w tle. Przy wolnym modelu może to potrwać kilka minut.",
|
||||
"waitingForWorker": "Oczekiwanie na proces w tle. Jeśli nic się nie zmienia, worker na serwerze może nie działać.",
|
||||
"errorWorkerUnavailable": "Proces w tle jest teraz niedostępny. Spróbuj ponownie za chwilę albo poproś administratora serwera o sprawdzenie go.",
|
||||
"errorTimedOut": "Odczyt trwał dłużej, niż pozwala ustawiony czas odpowiedzi AI. Spróbuj ponownie albo wydłuż czas odpowiedzi w ustawieniach AI."
|
||||
"errorTimedOut": "Odczyt trwał dłużej, niż pozwala ustawiony czas odpowiedzi AI. Spróbuj ponownie albo wydłuż czas odpowiedzi w ustawieniach AI.",
|
||||
"errorReaderBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nowy objaw",
|
||||
|
||||
+339
-80
@@ -175,6 +175,9 @@
|
||||
"failed": "Não foi possível ler o rótulo. Tente uma foto mais nítida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -435,7 +438,8 @@
|
||||
"autocorrelation": "forte arrasto de um dia para o outro, então as faixas ainda podem estar estreitas demais",
|
||||
"window": "analisado de {from} a {to}, o que pode ir além do período do relatório",
|
||||
"windowLine": "Analisado de {from} a {to}. Esta janela pode ir além do período do relatório."
|
||||
}
|
||||
},
|
||||
"screenerUnvalidated": "{label}: {count} de {total} resultados foram respondidos em uma tradução não validada cientificamente; considere-os como orientação."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo deu errado",
|
||||
@@ -628,7 +632,9 @@
|
||||
"title": "Convite inválido",
|
||||
"description": "Este link de convite está incompleto ou foi copiado errado. Peça um novo à pessoa que convidou você.",
|
||||
"toLogin": "Ir para o login"
|
||||
}
|
||||
},
|
||||
"deletionPending": "Esta conta será excluída em {date} e está bloqueada até lá. Um administrador deste servidor pode desfazer a exclusão antes dessa data.",
|
||||
"deletionPendingNoDate": "Esta conta está programada para exclusão e está bloqueada. Um administrador deste servidor pode desfazer a exclusão até que ela seja concluída."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
@@ -777,13 +783,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Faça backup da sua chave de criptografia",
|
||||
"body": "Sem uma cópia da chave, seus dados não podem ser recuperados após uma reinstalação. Leva um minuto.",
|
||||
"action": "Abrir a etapa"
|
||||
},
|
||||
"quickAddSymptom": "Registrar sintoma",
|
||||
"todayCardVisible": "Mostrar o cartão principal"
|
||||
"todayCardVisible": "Mostrar o cartão principal",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Guarde seu kit de recuperação",
|
||||
"body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.",
|
||||
"action": "Abrir o kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Medições",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"refusalInjection": "Percebi uma formulação que tenta substituir minhas instruções. Sou o Coach do HealthLog e só resumo seus próprios dados de saúde. Reformule sua pergunta nesses termos.",
|
||||
"errorBudget": "Você atingiu o limite de uso do Coach hoje. O orçamento é renovado à meia-noite no seu fuso horário.",
|
||||
"errorProvider": "O Coach não conseguiu alcançar um provedor de Insights agora. Tente novamente em instantes.",
|
||||
"errorReasoningOnly": "O modelo apenas raciocinou e não escreveu uma resposta. Tente novamente ou escolha outro modelo nas configurações de IA.",
|
||||
"errorDemo": "O Coach não responde na demonstração.",
|
||||
"errorNoProvider": "O Coach lê seus dados de saúde registrados e responde perguntas sobre eles (não é aconselhamento médico). O HealthLog precisa de um provedor de IA para isso. Conecte sua própria chave ou um modelo local.",
|
||||
"errorNoProviderAction": "Conectar um provedor de IA",
|
||||
@@ -5731,7 +5738,7 @@
|
||||
"passphrasePlaceholder": "Frase-senha (mín. 12 caracteres)",
|
||||
"passphraseHelp": "Deriva a chave do arquivo com Argon2id e a sela com AES-256-GCM. Salve o arquivo com a extensão .hlx.",
|
||||
"noRecoveryWarning": "Não há recuperação. Se você perder esta frase-senha, o arquivo não poderá ser aberto. Guarde-a em um lugar seguro.",
|
||||
"detail": "Mesmo formato do backup automático semanal. Alguns tipos de registro ainda não são incluídos; o manifesto do arquivo indica o que está nele.",
|
||||
"detail": "Uma cópia portátil do seu registro para guardar e ler: valores em JSON simples, documentos apenas com os metadados. Não é a cópia semanal da conta que este servidor mantém para reverter uma conta, e não consegue recriar uma conta em outro servidor. Alguns tipos de dados ainda não estão incluídos; o manifesto do arquivo informa o que ele contém.",
|
||||
"contents": "Suas medições, medicamentos, humor, ciclo, exames, episódios de doença, alergias, histórico familiar, nutrientes, treinos, perfil de saúde e métricas personalizadas em um único arquivo JSON."
|
||||
}
|
||||
},
|
||||
@@ -5777,7 +5784,7 @@
|
||||
"cycleDaysFailed": "{count} dias de ciclo não puderam ser gravados: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Arquivo JSON",
|
||||
"title": "Medições e humor (JSON)",
|
||||
"description": "Cole um JSON ou envie um arquivo .json com medições e registros de humor.",
|
||||
"pasteLabel": "Cole o JSON",
|
||||
"fileInputLabel": "Escolha um arquivo JSON para importar",
|
||||
@@ -5793,8 +5800,11 @@
|
||||
"readFailed": "Não foi possível ler esse arquivo.",
|
||||
"resultSummary": "Importadas {measurements} medições e {moods} registros de humor, {skipped} pulados.",
|
||||
"resultSuccess": "Importadas {measurements} medições e {moods} registros de humor",
|
||||
"droppedLinks": "Importado sem estas tags, porque esta conta não tem nenhuma com esse nome: {keys}",
|
||||
"keptLabels": "Esta conta já tem tags ou grupos com essas chaves e com nome próprio, então eles foram mantidos: {keys}",
|
||||
"resultNothing": "Nada foi importado. Todas as {skipped} entradas foram puladas.",
|
||||
"resultEmpty": "Esse arquivo não continha medições nem registros de humor."
|
||||
"resultEmpty": "Esse arquivo não continha medições nem registros de humor.",
|
||||
"limitsHint": "Até {mb} MB e até {rows} entradas em cada lista. Isso adiciona entradas ao seu registro; não restaura um backup completo."
|
||||
},
|
||||
"csv": {
|
||||
"title": "Arquivo CSV",
|
||||
@@ -5940,7 +5950,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporte seus dados ou importe de um backup."
|
||||
"subtitle": "Exporte seus dados ou importe medições e registros de outros apps e arquivos."
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre este aplicativo",
|
||||
@@ -6854,7 +6864,14 @@
|
||||
"detail": "O token é exibido uma vez.",
|
||||
"scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro. Não pode ler seus dados, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois, e você ainda pode corrigi-las você mesmo.",
|
||||
"createFailed": "O token não pôde ser criado. Tente novamente.",
|
||||
"namePlaceholder": "Nome, ex.: Home Assistant"
|
||||
"namePlaceholder": "Nome, ex.: Home Assistant",
|
||||
"appleHealth": {
|
||||
"label": "Gravar medições no Apple Health",
|
||||
"createHint": "O app HealthLog no seu iPhone passa a adicionar as medições deste token ao Apple Health. Você pode mudar isso depois na lista de tokens abaixo.",
|
||||
"toggleAria": "Gravar as medições de {name} no Apple Health",
|
||||
"updateFailed": "Não foi possível alterar a configuração do Apple Health. Tente novamente.",
|
||||
"unattributed": "As medições enviadas antes de esta configuração existir não podem ser associadas a um token, por isso ficam fora do Apple Health."
|
||||
}
|
||||
},
|
||||
"workoutsToken": {
|
||||
"title": "Tokens de treino",
|
||||
@@ -6911,10 +6928,12 @@
|
||||
"deleteAccountCardDescription": "Exclui sua conta junto com chaves de acesso, log de auditoria e sessões.",
|
||||
"deleteAccountCta": "Excluir conta",
|
||||
"deleteAccountConfirmTitle": "Excluir conta permanentemente?",
|
||||
"deleteAccountConfirmDescription": "Sua conta, todos os dados de saúde, chaves de acesso, sessões e registros de auditoria serão excluídos permanentemente. Você será desconectado imediatamente depois.",
|
||||
"deleteAccountConfirmTitleGrace": "Excluir sua conta?",
|
||||
"deleteAccountConfirmDescription": "Sua conta, todos os dados de saúde, chaves de acesso, sessões e registros de auditoria serão excluídos permanentemente. Você será desconectado imediatamente depois. As cópias nos backups deste servidor expiram depois do período de retenção.",
|
||||
"deleteAccountSuccess": "Conta excluída. Desconectando você.",
|
||||
"deleteAccountFailed": "Não foi possível excluir a conta",
|
||||
"deleteAccountFinal": "Excluir permanentemente",
|
||||
"deleteAccountFinalGrace": "Excluir conta",
|
||||
"saved": "Salvo",
|
||||
"savingError": "Erro ao salvar",
|
||||
"about": {
|
||||
@@ -7321,13 +7340,16 @@
|
||||
"coach": "Conversas do Coach: mantidas por {days} dias",
|
||||
"audit": "Log de atividade de segurança: mantido por {days} dias",
|
||||
"delivery": "Log de entrega de notificações: mantido por {days} dias",
|
||||
"unavailable": "Os detalhes de retenção estão indisponíveis agora."
|
||||
"unavailable": "Os detalhes de retenção estão indisponíveis agora.",
|
||||
"accountDeletion": "Excluir sua conta, um perfil gerenciado ou todos os dados: concluído depois de {days} dias",
|
||||
"accountDeletionImmediate": "Excluir sua conta, um perfil gerenciado ou todos os dados: concluído na hora"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Excluir ou redefinir",
|
||||
"description": "Remova sua conta por completo ou apague seus registros de saúde mantendo a conta.",
|
||||
"backupLag": "A exclusão remove seus dados imediatamente. Se este servidor mantiver backups criptografados fora do host, as cópias dos seus dados são excluídas desse armazenamento logo depois, ou na próxima execução noturna se o armazenamento estiver inacessível.",
|
||||
"openAdvanced": "Excluir ou redefinir"
|
||||
"openAdvanced": "Excluir ou redefinir",
|
||||
"immediateNotice": "A exclusão remove seus dados imediatamente. Se este servidor mantiver backups criptografados fora do host, as cópias dos seus dados são excluídas desse armazenamento logo depois, ou na próxima execução noturna se o armazenamento estiver inacessível. As cópias nos backups deste servidor expiram depois do período de retenção.",
|
||||
"graceNotice": "Quando você exclui sua conta, ela é bloqueada na hora e apagada depois de {days} dias, junto com todas as cópias que este servidor guarda dela, inclusive as cópias criptografadas fora do servidor. Até lá, um administrador deste servidor pode desfazer a exclusão. Excluir todos os dados funciona do mesmo jeito: uma cópia do registro é mantida por {days} dias, para que um administrador possa restaurá-la. As cópias nos backups deste servidor expiram depois do período de retenção."
|
||||
},
|
||||
"posture": {
|
||||
"title": "Postura de privacidade",
|
||||
@@ -7633,7 +7655,13 @@
|
||||
"off": "Desligado",
|
||||
"change": "Alterar em Módulos"
|
||||
},
|
||||
"timezoneUseDevice": "Usar o fuso horário deste dispositivo ({zone})"
|
||||
"timezoneUseDevice": "Usar o fuso horário deste dispositivo ({zone})",
|
||||
"dangerZoneConfirmDescriptionGrace": "Isso exclui tudo no seu registro: medições, medicamentos, registros de humor, resultados de exames, registros de ciclo, documentos, conversas de IA e todas as conexões de integração, junto com tudo derivado deles e qualquer coisa que você tenha excluído antes. Os links de compartilhamento que você deu a um médico param de funcionar imediatamente. Sua conta permanece, junto com as credenciais que você usa para entrar e as configurações de exibição, como idioma, fuso horário e unidades. Uma cópia do seu registro é mantida por {days} dias, para que um administrador deste servidor possa restaurá-la até lá. Depois disso, a exclusão é definitiva.",
|
||||
"deleteAccountCardDetailGrace": "Sua conta é bloqueada na hora e apagada depois de {days} dias. Até lá, um administrador deste servidor pode desfazer a exclusão.",
|
||||
"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."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administração",
|
||||
@@ -8069,7 +8097,15 @@
|
||||
"cannotLogoutSelf": "Não é possível forçar o próprio logout",
|
||||
"emptyTitle": "Nenhum usuário nesta visão",
|
||||
"emptyDescription": "Não há usuários correspondentes ao filtro atual.",
|
||||
"emptyResetFilter": "Mostrar todos os usuários"
|
||||
"emptyResetFilter": "Mostrar todos os usuários",
|
||||
"deletionPending": "Exclusão em {date}",
|
||||
"deletionPendingNoDate": "Exclusão pendente",
|
||||
"undoDeletion": "Desfazer exclusão",
|
||||
"undoDeletionTitle": "Desfazer a exclusão de {name}?",
|
||||
"undoDeletionBody": "{name} pediu para excluir esta conta, e ela é apagada quando o prazo de carência termina. Desfazer mantém a conta e tudo o que há nela como estava. {name} pode entrar de novo, mas os tokens de API, os links de compartilhamento e os dispositivos confiáveis continuam revogados e precisam ser configurados de novo.",
|
||||
"undoDeletionBodyGuardian": "Um guardião excluiu o perfil gerenciado {name}, e ele é apagado quando o prazo de carência termina. Desfazer mantém o perfil e tudo o que há nele como estava, e os guardiões voltam a ter acesso. Os links de compartilhamento e tokens continuam revogados e precisam ser configurados de novo.",
|
||||
"undoDeletionDone": "Exclusão de {name} desfeita",
|
||||
"undoDeletionFailed": "Não foi possível desfazer a exclusão"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Convites",
|
||||
@@ -8131,8 +8167,8 @@
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"subtitle": "Instantâneos semanais de dados por usuário armazenados no PostgreSQL.",
|
||||
"description": "Cada usuário recebe um instantâneo contínuo por tipo de backup. Os tamanhos mostram o blob criptografado. O download descriptografa o instantâneo para o administrador que o solicita; o texto cifrado permanece no banco de dados.",
|
||||
"subtitle": "Cópias de contas no banco de dados deste servidor, para reverter uma conta aqui.",
|
||||
"description": "Cada conta tem uma cópia semanal, substituída todo domingo, além das cópias enviadas aqui e da cópia feita antes de \"Excluir todos os dados\". Uma cópia reverte uma conta existente neste servidor. Ela não reconstrói um servidor perdido: para isso são necessários um dump do banco de dados e as chaves de criptografia. Os tamanhos mostram a cópia criptografada. O download a descriptografa para o administrador que pede; o texto cifrado fica no banco de dados.",
|
||||
"runNow": "Fazer backup agora",
|
||||
"runEnqueued": "Tarefa de backup enfileirada",
|
||||
"runFailed": "Falha ao enfileirar o backup",
|
||||
@@ -8142,12 +8178,11 @@
|
||||
"emptyDescription": "Os backups rodam automaticamente todos os domingos às 03:00, ou acione um agora para criar o primeiro instantâneo.",
|
||||
"colUser": "Usuário",
|
||||
"colType": "Tipo",
|
||||
"typeWeeklyAuto": "Semanal (automático)",
|
||||
"typeWeeklyAuto": "Cópia semanal da conta",
|
||||
"typeManual": "Manual",
|
||||
"colSize": "Tamanho",
|
||||
"colCreatedAt": "Último instantâneo",
|
||||
"colActions": "Ações",
|
||||
"retentionLabel": "Retenção: {days} dias",
|
||||
"scheduleStaleTitle": "O backup semanal parou",
|
||||
"scheduleStaleDescription": "A cópia agendada mais recente tem {days} dias. Uma execução semanal funcional nunca deixa uma com mais de {threshold} dias.",
|
||||
"scheduleFailedTitle": "A última execução agendada de backup falhou",
|
||||
@@ -8165,7 +8200,7 @@
|
||||
"downloadFailed": "Falha no download",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"uploadTitle": "Enviar backup",
|
||||
"uploadDescription": "Valide um arquivo de backup e armazene-o como instantâneo manual. A restauração é uma etapa separada.",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.",
|
||||
"uploadButton": "Enviar arquivo",
|
||||
"uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.",
|
||||
"uploadSuccess": "Backup enviado ({count} registros)",
|
||||
@@ -8225,7 +8260,7 @@
|
||||
"restoreSkippedAccountSetting": "Configuração da conta",
|
||||
"restoreMissingTitle": "Restauração recusada: o arquivo está incompleto",
|
||||
"restoreMissingDescription": "O próprio manifesto do arquivo diz que ele carrega a seção abaixo, e não carrega. Nada foi alterado: uma restauração substitui uma classe inteira de registros, então recolocar uma seção que não está no arquivo a teria esvaziado.",
|
||||
"restoreMissingRemedy": "Use um instantâneo de recuperação de desastre, ou exporte a conta novamente e envie o novo arquivo. Uma seção que o manifesto declara como omitida é uma coisa diferente e restaura normalmente.",
|
||||
"restoreMissingRemedy": "Use uma das cópias de conta deste servidor ou uma cópia fora do servidor, ou exporte a conta de novo e envie o arquivo novo. Uma seção que o manifesto declara como omitida é outra coisa e é restaurada normalmente.",
|
||||
"restoreMissingDocuments": "Documentos",
|
||||
"restoreMissingWorkouts": "Treinos",
|
||||
"restoreMissingMentalHealth": "Triagens de saúde mental",
|
||||
@@ -8255,6 +8290,9 @@
|
||||
"restoreActive": "Uma restauração desta conta já está em execução. Aguarde a conclusão.",
|
||||
"restoreJobQueuedTitle": "A restauração de {username} está aguardando para iniciar",
|
||||
"restoreJobQueuedNote": "Ela começa assim que o worker em segundo plano a pegar.",
|
||||
"restoreJobWaitingBackupNote": "Aguardando o backup que está em execução no servidor. Uma restauração nunca roda ao mesmo tempo que um backup e começa cerca de um minuto depois que ele termina.",
|
||||
"restoreJobWaitingBusyNote": "Aguardando um trabalho grande no servidor terminar, como uma importação ou uma rotina noturna. Ela começa cerca de um minuto depois.",
|
||||
"restoreJobQueuedSince": "Na fila desde {when}.",
|
||||
"restoreJobRunningTitle": "Restaurando {username}",
|
||||
"restoreJobRunningNote": "A conta continua exibindo seus dados atuais até a restauração terminar. Você pode sair desta página; a restauração continua no servidor.",
|
||||
"restoreJobResumed": "Iniciada novamente após o servidor reiniciar.",
|
||||
@@ -8275,7 +8313,7 @@
|
||||
"restoreFailureOwnerMismatch": "O arquivo dentro do backup pertence a uma conta diferente da linha de backup.",
|
||||
"restoreFailureOwnerMissing": "A conta à qual este backup pertence não existe mais.",
|
||||
"restoreFailureSectionMissing": "O arquivo está incompleto: seu manifesto nomeia uma seção que ele não carrega.",
|
||||
"restoreFailureDocument": "O arquivo carrega documentos sem seu conteúdo, como faz uma exportação portátil. Use um backup de recuperação de desastre.",
|
||||
"restoreFailureDocument": "O arquivo traz documentos sem o conteúdo, como uma exportação portátil. Use uma das cópias de conta deste servidor ou uma cópia fora do servidor.",
|
||||
"restoreFailureTimeBudget": "Este backup é grande demais para restaurar dentro do limite de tempo da tarefa em segundo plano.",
|
||||
"restoreFailureTransaction": "O banco de dados recusou a restauração no meio do caminho, então tudo foi revertido. O log do servidor nomeia o erro.",
|
||||
"restoreFailureCauseTimeout": "A restauração levou mais tempo do que o banco de dados permitia, então tudo foi revertido. Se o host do banco estava ocupado, inicie a restauração novamente quando estiver mais tranquilo. O log do servidor nomeia a etapa.",
|
||||
@@ -8296,7 +8334,32 @@
|
||||
"restoreSkippedMoodLabelCiphertext": "Etiqueta ou nome de grupo de humor selado com uma chave que este servidor não possui (mantido sem o nome)",
|
||||
"restoreSkippedVisitCiphertext": "Texto de consulta ou profissional selado com uma chave que este servidor não possui (mantido sem ele)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota de vacinação selada com uma chave que este servidor não possui (dose mantida sem ela)",
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)"
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)",
|
||||
"typePreWipe": "Antes da exclusão de todos os dados",
|
||||
"retentionNote": "A cópia semanal é substituída todo domingo. Uma cópia feita antes de \"Excluir todos os dados\" é excluída depois de {days} dias. As cópias enviadas ficam até a conta delas ser apagada.",
|
||||
"retentionNoteNoGrace": "A cópia semanal é substituída todo domingo. As cópias enviadas ficam até a conta delas ser apagada.",
|
||||
"database": {
|
||||
"title": "Dump do banco de dados",
|
||||
"lastGood": "Último dump válido {when}, {size}, {where}.",
|
||||
"whereBoth": "guardado neste servidor e fora dele",
|
||||
"whereLocal": "guardado neste servidor",
|
||||
"whereOffhost": "somente fora do servidor",
|
||||
"none": "Ainda não há dump do banco de dados. O primeiro é feito esta noite.",
|
||||
"failedTitle": "O último dump do banco de dados falhou",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Os dumps do banco de dados estão desligados (DB_BACKUP_ENABLED=false). Faça o backup do banco de outra forma.",
|
||||
"noDirectoryOffhost": "Este servidor não consegue gravar em {dir}, então os dumps vão só para fora e nenhum fica aqui. Monte o volume de backups.",
|
||||
"noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups.",
|
||||
"retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} dumps noturnos mais recentes, e o dump feito antes da última atualização fica até a próxima.",
|
||||
"retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.",
|
||||
"retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.",
|
||||
"notPersistentOffhost": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória), então um dump gravado ali se perde na próxima atualização. Os dumps vão só para fora: nenhum fica aqui e nenhum é feito antes de uma migração. Monte o volume de backups.",
|
||||
"notPersistent": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória) e nenhum bucket externo está configurado, então nenhum dump do banco é guardado em lugar nenhum. Monte o volume de backups.",
|
||||
"offhostFailedTitle": "O dump mais recente do banco de dados não chegou ao bucket externo",
|
||||
"offhostLastUpload": "O dump mais recente no bucket é de {when}.",
|
||||
"offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de perigo",
|
||||
@@ -8362,7 +8425,10 @@
|
||||
"columnsHide": "Ocultar a tabela",
|
||||
"columnsShowAll": "Mostrar todas as {count} colunas",
|
||||
"columnsShowWithRows": "Somente colunas com linhas",
|
||||
"columnsNoneWithRows": "Nenhuma coluna criptografada contém linhas ainda."
|
||||
"columnsNoneWithRows": "Nenhuma coluna criptografada contém linhas ainda.",
|
||||
"backupsDumpLocalLine": "Chave {key}: {count} dumps do banco de dados neste servidor foram gravados com ela; ela é necessária até que a retenção os remova.",
|
||||
"backupsDumpOffhostLine": "Chave {key}: o dump do banco de dados mais recente gravado com ela foi para o bucket em {when}; necessária lá até {until}",
|
||||
"backupsDumpOffhostLineUnknown": "Chave {key}: o dump do banco de dados mais recente gravado com ela foi para o bucket em {when}. Não se sabe por quanto tempo o bucket mantém database/, então mantenha a chave até saber que aquele dump se foi."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Manutenção da tabela de medições",
|
||||
@@ -8492,47 +8558,6 @@
|
||||
"title": "Esta parte da página não pôde ser exibida",
|
||||
"description": "O resto da página ainda funciona; tente novamente ou recarregue se continuar falhando."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Faça backup da sua chave de criptografia",
|
||||
"description": "Sem uma cópia desta chave, seus dados criptografados e os backups deste servidor não podem ser lidos novamente.",
|
||||
"loading": "Carregando o status do backup da chave…",
|
||||
"loadError": "Não foi possível carregar o status do backup da chave.",
|
||||
"badgeDue": "A fazer",
|
||||
"badgeDone": "Backup feito",
|
||||
"keyId": "ID da chave ativa",
|
||||
"fingerprint": "Impressão digital",
|
||||
"whatIsLost": "Mantenha uma cópia da chave em algum lugar fora deste servidor, por exemplo em um gerenciador de senhas. Se o servidor for reinstalado com uma chave diferente, o HealthLog se recusa a começar a servir até a chave original voltar.",
|
||||
"fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 da chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.",
|
||||
"checkLabel": "Verificar minha cópia",
|
||||
"checkButton": "Verificar",
|
||||
"checkNote": "A chave que você cola é comparada com a chave ativa e depois descartada. Ela nunca é armazenada ou registrada.",
|
||||
"checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.",
|
||||
"checkMatches": "Sua cópia corresponde à chave ativa.",
|
||||
"checkNoMatch": "Sua cópia não corresponde à chave ativa. Verifique se você copiou o valor inteiro.",
|
||||
"checkFailed": "A verificação falhou.",
|
||||
"confirmButton": "Eu fiz backup desta chave",
|
||||
"confirmed": "Backup da chave confirmado.",
|
||||
"confirmFailed": "Não foi possível salvar a confirmação.",
|
||||
"stale": "A chave do servidor mudou desde que esta página carregou. Verifique o novo ID e a impressão digital e confirme novamente.",
|
||||
"confirmedAt": "Confirmado em {date}.",
|
||||
"confirmedAtBy": "Confirmado em {date} por {who}.",
|
||||
"dueAgain": "Um backup foi confirmado para a chave {keyId}, mas o servidor agora usa outra. Faça backup da atual.",
|
||||
"tileLabel": "Backup da chave de criptografia",
|
||||
"tileDue": "Não confirmado",
|
||||
"tileDone": "Confirmado",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "A chave é ENCRYPTION_KEY (ou ENCRYPTION_KEYS) no arquivo .env ao lado do docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog, escolha Editar e copie ENCRYPTION_KEY das configurações de ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY de suas Variáveis de Ambiente.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY de suas variáveis de ambiente."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "O HealthLog não consegue abrir seus dados",
|
||||
"body": "A chave de criptografia com que este servidor foi iniciado não é a chave com que seu banco de dados foi gravado. Para proteger seus dados, o HealthLog não serve nada até que isso seja corrigido.",
|
||||
@@ -8563,6 +8588,185 @@
|
||||
"defaultAboveCeiling": "O nível padrão não pode estar acima do nível mais alto.",
|
||||
"gapLabel": "Horas entre atualizações de cartão após uma sincronização (Padrão)",
|
||||
"gapHint": "Horas inteiras de 1 a 48. Deixe em branco para usar AI_SYNC_REGEN_GAP_HOURS, senão 6. Em vigor agora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de recuperação",
|
||||
"description": "Todas as chaves de que uma restauração deste servidor precisa, com ID e impressão digital.",
|
||||
"intro": "Mantenha uma cópia de cada chave abaixo fora deste servidor, por exemplo em um gerenciador de senhas. Sem elas, nem um dump do banco de dados nem uma cópia de backup podem ser lidos novamente, e o HealthLog se recusa a iniciar se faltar uma chave de que ele precisa.",
|
||||
"loading": "Carregando o kit de recuperação…",
|
||||
"loadError": "Não foi possível carregar o kit de recuperação.",
|
||||
"badgeDue": "A fazer",
|
||||
"badgeDone": "Guardado",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Impressão digital",
|
||||
"noFingerprint": "Não está neste servidor",
|
||||
"kind": {
|
||||
"application": "Chave do aplicativo {id}",
|
||||
"backup": "Chave de backup fora do host",
|
||||
"backupPrevious": "Chave anterior de backup fora do host",
|
||||
"token": "Chave de tokens"
|
||||
},
|
||||
"need": {
|
||||
"active": "Criptografa os dados novos.",
|
||||
"configured": "Mantida por este servidor.",
|
||||
"storedCopies": "Necessária para {count} cópias armazenadas.",
|
||||
"offhostUntil": "Necessária para as cópias fora do host até {date}.",
|
||||
"offhostUnknown": "Necessária para as cópias fora do host enquanto o bucket as mantiver.",
|
||||
"backupActive": "Sela cada nova cópia fora do host.",
|
||||
"backupPrevious": "Abre as cópias fora do host gravadas antes da última troca de chave.",
|
||||
"token": "Assina sessões, tokens e links de compartilhamento. Sem ela, todos são desconectados, mas nenhum dado é perdido.",
|
||||
"localDumps": "Necessária para {count} dumps do banco de dados neste servidor.",
|
||||
"offhostDumpsUntil": "Necessária para os dumps do banco de dados fora do host até {date}.",
|
||||
"offhostDumpsUnknown": "Necessária para os dumps do banco de dados fora do host enquanto o bucket os mantiver."
|
||||
},
|
||||
"source": "Definida em {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Guardada",
|
||||
"new": "Ainda não guardada",
|
||||
"notConfigured": "Não está neste servidor",
|
||||
"unreadable": "Não é uma chave válida"
|
||||
},
|
||||
"notConfiguredHint": "Um backup ainda precisa desta chave, e este servidor não a tem. Só uma cópia que você guarda em outro lugar consegue ler esses backups. Confirme o kit quando tiver essa cópia.",
|
||||
"unreadableHint": "A variável está definida, mas não contém uma chave válida. Corrija isso antes de contar com as cópias fora do host.",
|
||||
"fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 de uma chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.",
|
||||
"whereTitle": "Onde as chaves são definidas",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "As chaves ficam no arquivo .env ao lado do docker-compose.yml: ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, com cópias fora do host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog e escolha Edit. As chaves ficam em HealthLog Configuration; em um app instalado via YAML, são ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nas configurações de ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas variáveis de ambiente."
|
||||
},
|
||||
"restoreTitle": "Restaurar este servidor",
|
||||
"restoreBody": "Instale o HealthLog com as mesmas chaves, restaure o dump do banco de dados mais recente em um banco de dados vazio e depois inicie o app. O guia mostra cada etapa.",
|
||||
"restoreLink": "Guia de recuperação de desastres",
|
||||
"checkLabel": "Verificar minha cópia",
|
||||
"checkKeyLabel": "Chave a verificar",
|
||||
"checkValueLabel": "Sua cópia da chave",
|
||||
"checkButton": "Verificar",
|
||||
"checkNote": "A chave que você cola é comparada com a que este servidor mantém e depois descartada. Ela nunca é armazenada ou registrada.",
|
||||
"checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.",
|
||||
"checkMatches": "Sua cópia corresponde a esta chave.",
|
||||
"checkNoMatch": "Sua cópia não corresponde a esta chave. Verifique se você copiou o valor inteiro.",
|
||||
"checkFailed": "A verificação falhou.",
|
||||
"confirmButton": "Tenho uma cópia de cada chave",
|
||||
"confirmed": "Kit de recuperação confirmado.",
|
||||
"confirmFailed": "Não foi possível salvar a confirmação.",
|
||||
"stale": "O kit mudou desde que esta página carregou. Verifique as chaves que ele lista agora e confirme novamente.",
|
||||
"confirmedAt": "Confirmado em {date}.",
|
||||
"confirmedAtBy": "Confirmado em {date} por {who}.",
|
||||
"dueAgain": "O kit foi confirmado em {date}. Agora ele também lista {keys}, que essa confirmação não cobria. Guarde uma cópia de cada uma e confirme de novo."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Prontidão para recuperação",
|
||||
"description": "Se este servidor poderia ser reconstruído após uma perda, avaliado a partir de cada backup que ele faz.",
|
||||
"loading": "Verificando os backups…",
|
||||
"loadError": "Não foi possível carregar a prontidão para recuperação.",
|
||||
"state": {
|
||||
"ok": "Pronto",
|
||||
"attention": "Precisa de atenção",
|
||||
"failing": "Em risco"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump do banco de dados",
|
||||
"weekly_copy": "Cópias semanais das contas",
|
||||
"offhost_copies": "Cópias fora do host",
|
||||
"restore_drill": "Teste de restauração",
|
||||
"offhost_setup": "Backup fora do host",
|
||||
"recovery_kit": "Kit de recuperação",
|
||||
"dump_retention": "Dumps no bucket"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump mais recente: {date}",
|
||||
"weekly_copy": "Cópia de conta mais antiga: {date}",
|
||||
"offhost_copies": "Cópia fora do host mais antiga: {date}",
|
||||
"restore_drill": "Último teste: {date}",
|
||||
"recovery_kit": "Confirmado: {date}"
|
||||
},
|
||||
"dueBy": "Prazo: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Abrir backups",
|
||||
"weekly_copy": "Abrir backups",
|
||||
"offhost_copies": "Abrir backups",
|
||||
"restore_drill": "Abrir backups",
|
||||
"offhost_setup": "Abrir backups",
|
||||
"recovery_kit": "Abrir o kit",
|
||||
"dump_retention": "Abrir backups"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Esta versão não agenda um dump do banco de dados.",
|
||||
"disabled": "O dump agendado do banco de dados está desligado, então nenhuma cópia consegue reconstruir este servidor. Para religá-lo, remova DB_BACKUP_ENABLED ou defina-o como true e reinicie o HealthLog.",
|
||||
"unreadable": "Não foi possível ler o status do dump do banco de dados.",
|
||||
"never_succeeded": "Nenhum dump do banco de dados foi concluído com sucesso ainda, e a última tentativa falhou.",
|
||||
"never_ran": "Nenhum dump do banco de dados foi gravado nas {hours} horas desde que os dumps foram ativados.",
|
||||
"first_pending": "O primeiro dump do banco de dados ainda não foi executado.",
|
||||
"stale": "O dump do banco de dados mais recente tem {hours} horas.",
|
||||
"last_failed": "O último dump do banco de dados falhou. O anterior ainda é recente.",
|
||||
"fresh": "O dump do banco de dados mais recente está em dia.",
|
||||
"nowhere_to_write": "Não é possível gravar no diretório de dumps {directory} e nenhum bucket externo está configurado, então nenhum dump pode ser guardado.",
|
||||
"not_persistent": "A pasta de dumps {directory} não está em armazenamento persistente e nenhum bucket externo está configurado, então cada dump se perde na próxima atualização. Monte o volume de backups.",
|
||||
"fresh_offhost_only": "O dump mais recente do banco é atual e está no bucket externo. Nenhum fica neste servidor, porque {directory} não está em armazenamento persistente, e nenhum é feito antes de uma migração.",
|
||||
"offhost_failed": "O dump mais recente do banco de dados está guardado neste servidor, mas o envio para o bucket externo falhou.",
|
||||
"offhost_stale": "Nenhum dump do banco de dados chegou ao bucket externo em {hours} horas: os envios continuam falhando. Se este servidor for perdido, não há um dump recente para reconstruí-lo.",
|
||||
"offhost_never": "Nenhum dump do banco de dados jamais chegou ao bucket externo: o envio falha. Se este servidor for perdido, não há dump para reconstruí-lo."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Ainda não há contas para copiar.",
|
||||
"stale": "A cópia semanal mais antiga tem {days} dias. Contas atrasadas: {accounts}.",
|
||||
"never": "Uma conta está sem cópia semanal há {days} dias. Contas atrasadas: {accounts}.",
|
||||
"first_pending": "A primeira cópia semanal é feita na madrugada de domingo.",
|
||||
"fresh": "Todas as contas têm uma cópia da última semana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Nenhum bucket fora do host está configurado.",
|
||||
"stale": "As cópias fora do host estão mais de duas noites atrasadas. Contas atrasadas: {accounts}.",
|
||||
"first_pending": "A execução noturna fora do host ainda não chegou a nenhuma conta.",
|
||||
"fresh": "Todas as contas têm uma cópia recente no bucket fora do host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "O teste lê o bucket fora do host, que não está configurado.",
|
||||
"not_run_yet": "O teste mensal de restauração ainda não foi executado.",
|
||||
"failed": "O último teste de restauração falhou: {failed} de {checked} cópias não puderam ser lidas de volta.",
|
||||
"failed_run": "O último teste de restauração não pôde ser concluído.",
|
||||
"passed": "O último teste de restauração leu de volta {checked} cópias.",
|
||||
"nothing_checked": "O último teste de restauração parou no limite de tempo antes de ler de volta uma única cópia, então não comprovou nada."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "As cópias saem deste servidor todas as noites.",
|
||||
"no_admin": "Ainda não há uma conta de administrador.",
|
||||
"not_configured": "Nenhum bucket fora do host está configurado, então todas as cópias ficam apenas neste servidor.",
|
||||
"not_configured_grace": "Nenhum bucket fora do host foi configurado ainda, então todas as cópias ficam apenas neste servidor."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Há uma cópia guardada de cada chave de que uma restauração precisa.",
|
||||
"out_of_date": "O kit de recuperação agora também lista {keys}, que a última confirmação não cobria. Nada quebrou: guarde uma cópia e confirme o kit de novo.",
|
||||
"no_admin": "Ainda não há uma conta de administrador.",
|
||||
"not_confirmed": "Ninguém confirmou uma cópia das chaves de que uma restauração precisa: {keys}.",
|
||||
"not_confirmed_grace": "Guarde uma cópia das chaves de que uma restauração precisa: {keys}."
|
||||
},
|
||||
"dump_retention": {
|
||||
"not_configured": "Nenhum bucket externo está configurado.",
|
||||
"no_offhost_dumps": "Nenhum dump do banco vai para o bucket.",
|
||||
"missing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.",
|
||||
"configured": "O bucket remove os dumps após {days} dias.",
|
||||
"unknown": "O bucket não informa se alguma regra remove dumps antigos. Confira se uma regra de ciclo de vida cobre database/."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "O dump do banco de dados está em dia novamente.",
|
||||
"weekly_copy": "Todas as contas têm novamente uma cópia semanal recente.",
|
||||
"offhost_copies": "Todas as contas têm novamente uma cópia recente fora do host.",
|
||||
"restore_drill": "O teste de restauração passou novamente.",
|
||||
"offhost_setup": "As cópias fora do host estão configuradas agora.",
|
||||
"recovery_kit": "O kit de recuperação está confirmado novamente.",
|
||||
"dump_retention": "Agora uma regra de ciclo de vida do bucket cobre os dumps do banco."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -8876,7 +9080,10 @@
|
||||
"offlineGeoUnavailableTitle": "Bancos de dados de geolocalização offline não carregados",
|
||||
"offlineGeoUnavailableBody": "O HealthLog está resolvendo locais de login pela consulta online em {host}, então cada endereço IP de login sai deste servidor. Para resolvê-los localmente, baixe os bancos de dados GeoLite2-City e GeoLite2-ASN com uma conta gratuita da MaxMind, monte o diretório no contêiner e aponte GEOLITE2_DIR para ele. Passos: {docsUrl}",
|
||||
"tlsPinChangedTitle": "Certificado folha TLS alterado: {host}",
|
||||
"tlsPinChangedBody": "O certificado folha TLS servido por {host} agora tem o pin SPKI {servedPin}, que não está no conjunto de pins conhecidos. Clientes com pin (o aplicativo nativo) falharão ao conectar quando o pin antigo sumir. Extraia novamente o pin da nova folha, adicione-o a TLS_LEAF_SPKI_PINS e ao conjunto de pins do cliente nativo, e publique uma build antes que este certificado expire em {validTo}. Veja docs/ops/tls-cert-pin.md."
|
||||
"tlsPinChangedBody": "O certificado folha TLS servido por {host} agora tem o pin SPKI {servedPin}, que não está no conjunto de pins conhecidos. Clientes com pin (o aplicativo nativo) falharão ao conectar quando o pin antigo sumir. Extraia novamente o pin da nova folha, adicione-o a TLS_LEAF_SPKI_PINS e ao conjunto de pins do cliente nativo, e publique uma build antes que este certificado expire em {validTo}. Veja docs/ops/tls-cert-pin.md.",
|
||||
"accountErasureRefusedTitle": "Não foi possível apagar a conta: {username}",
|
||||
"accountErasureRefusedBody": "O prazo de exclusão de {username} terminou, mas a conta é o último guardião ativo de um perfil gerenciado, então não foi apagada. Ela continua bloqueada. Adicione outro guardião ao perfil, exclua o perfil ou desfaça a exclusão em Administração, Usuários.",
|
||||
"accountErasureFailedBody": "O prazo de exclusão de {username} terminou, mas apagar a conta falhou. Ela continua bloqueada e uma nova tentativa é feita a cada hora, depois das outras contas pendentes. O erro aparece no cartão de tarefas com falha em Administração."
|
||||
},
|
||||
"user": {
|
||||
"telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas."
|
||||
@@ -9028,6 +9235,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Lembretes de check-up",
|
||||
"description": "Ligue ou desligue lembretes de check-up individuais."
|
||||
},
|
||||
"eventBackupHealth": "Saúde dos backups",
|
||||
"eventBackupHealthDesc": "Enviado quando um backup para, fica atrasado ou se recupera. Apenas para administradores.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Os backups precisam de atenção",
|
||||
"titleFailing": "Os backups estão em risco",
|
||||
"titleReminder": "Os backups continuam em risco",
|
||||
"titleRecovered": "Os backups voltaram ao normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11054,7 +11269,6 @@
|
||||
"who5": "Uma pontuação de bem-estar de 50 ou abaixo pode valer uma olhada mais de perto. Se quiser, o check-in PHQ-9 nesta página examina o humor com mais profundidade. Esta é uma sugestão gentil, não um diagnóstico.",
|
||||
"sci": "No estudo por trás deste questionário, pontuações de 16 ou abaixo foram associadas a provável insônia. Se o sono ruim continuar incomodando, conversar com um profissional de saúde pode ajudar. Isto não é um diagnóstico."
|
||||
},
|
||||
"validatedInEnglishNote": "Este questionário é validado em inglês; as perguntas são exibidas na redação original em inglês.",
|
||||
"openDetail": "Mostrar detalhes",
|
||||
"historyLoadError": "Não foi possível carregar seu histórico de avaliações",
|
||||
"historyLoadErrorHint": "Isso não significa que você não tem resultados; a lista não pôde ser buscada. Tente novamente.",
|
||||
@@ -11104,6 +11318,13 @@
|
||||
"belowThreshold": "Seu sono no último mês ficou na faixa em que o estudo por trás deste questionário considerou provável uma insônia.",
|
||||
"aboveThreshold": "Seu sono no último mês ficou fora da faixa que esse estudo associa à insônia."
|
||||
}
|
||||
},
|
||||
"wording": {
|
||||
"unvalidatedNote": "Esta tradução não foi validada cientificamente; considere o resultado como uma orientação.",
|
||||
"showOriginal": "Ver o texto original em inglês",
|
||||
"showTranslation": "Ver a tradução",
|
||||
"resultUnvalidated": "Respondido em uma tradução que não foi validada cientificamente; considere o resultado como uma orientação.",
|
||||
"historyUnvalidated": "Respondido em uma tradução não validada cientificamente"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
@@ -11631,7 +11852,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Já armazenado. O documento existente está destacado.",
|
||||
"deleted": "Documento excluído",
|
||||
"restoreFailed": "Não foi possível restaurar o documento."
|
||||
"restoreFailed": "Não foi possível restaurar o documento.",
|
||||
"deletedHint": "Documentos excluídos ficam em Excluídos recentemente por {days} dias."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Carregando mais…",
|
||||
@@ -11676,7 +11898,8 @@
|
||||
"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.",
|
||||
"empty-transcription": "O provedor de IA não retornou texto para este documento."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"loadError": "Não foi possível carregar este documento.",
|
||||
@@ -11748,7 +11971,7 @@
|
||||
"dismiss": "Dispensar",
|
||||
"empty": "Nada pôde ser lido deste documento.",
|
||||
"unavailableBody": "Configure um provedor de IA para sugerir um título, tipo e data.",
|
||||
"unavailableLocalOcr": "Ative o OCR local nas configurações de IA para ler imagens neste dispositivo.",
|
||||
"unavailableLocalOcr": "Ative “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames para ler imagens neste dispositivo.",
|
||||
"unavailableAction": "Abrir configurações de IA",
|
||||
"errorGeneric": "Não foi possível ler o documento. Tente novamente.",
|
||||
"errorOcr": "Não foi possível ler texto desta imagem.",
|
||||
@@ -11760,11 +11983,13 @@
|
||||
"errorPdfNeedsVision": "Digitalizar PDFs precisa de um provedor de visão Claude.",
|
||||
"errorFileType": "Este tipo de arquivo não pode ser lido.",
|
||||
"errorNotIndexed": "Leia o documento primeiro, depois extraia.",
|
||||
"errorLocalOcrDisabled": "Ative o OCR local nas configurações de IA primeiro.",
|
||||
"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.",
|
||||
"errorLocalOcrDisabled": "Ative primeiro “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames.",
|
||||
"errorProvider": "Nenhum provedor de IA está configurado para isso.",
|
||||
"errorTextImageOnly": "O OCR local lê apenas imagens, não este arquivo.",
|
||||
"errorConsent": "Ler documentos com IA precisa do seu consentimento primeiro.",
|
||||
"errorUnavailable": "Ler documentos com IA não está disponível aqui agora."
|
||||
"errorUnavailable": "Ler documentos com IA não está disponível aqui agora.",
|
||||
"unavailableLocalOcrAction": "Abrir configurações de exames"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Resumir",
|
||||
@@ -11820,6 +12045,7 @@
|
||||
"extracting": "Extraindo…",
|
||||
"extractHint": "Usa o texto já lido deste documento. Valores extraídos aguardam sua revisão; nada é salvo automaticamente.",
|
||||
"extractEmpty": "Nenhum valor pôde ser extraído deste documento.",
|
||||
"extractIncomplete": "Parte do documento não pôde ser lida por completo, então podem faltar valores. Confira a lista com o documento.",
|
||||
"failUnitRequired": "Um valor numérico precisa de uma unidade antes de poder ser salvo.",
|
||||
"failUnitMismatch": "A unidade informada não corresponde à unidade salva para este marcador. Ajuste-a e conclua a revisão novamente.",
|
||||
"failGeneric": "Este valor não pôde ser salvo.",
|
||||
@@ -11867,6 +12093,7 @@
|
||||
"loadMore": "Carregar mais",
|
||||
"stateImported": "No HealthLog",
|
||||
"stateDeleted": "Excluído no HealthLog",
|
||||
"stateInTrash": "Em Excluídos recentemente",
|
||||
"selectedCount": "{count} de até {max} selecionados",
|
||||
"importAction": "Importar {count}",
|
||||
"importing": "Importando, um documento por vez…",
|
||||
@@ -11880,7 +12107,9 @@
|
||||
"importedLinked": "Importado e vinculado",
|
||||
"duplicate": "Já no HealthLog",
|
||||
"duplicateLinked": "Já no HealthLog, agora vinculado",
|
||||
"deleted": "Excluído no HealthLog, deixado como está"
|
||||
"deleted": "Excluído no HealthLog, deixado como está",
|
||||
"restored": "Restaurado de Excluídos recentemente",
|
||||
"restoredLinked": "Restaurado de Excluídos recentemente e vinculado"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Arquivos de documentos não estão habilitados neste servidor.",
|
||||
@@ -11924,8 +12153,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Excluir este documento?",
|
||||
"titleMany": "Excluir {count} documentos?",
|
||||
"body": "Você pode desfazer isso logo depois. Após 30 dias, é removido para sempre.",
|
||||
"body": "Documentos excluídos ficam em Excluídos recentemente por {days} dias e depois são removidos para sempre.",
|
||||
"action": "Excluir"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Excluídos recentemente",
|
||||
"subtitle": "Documentos excluídos ficam aqui por {days} dias e depois são removidos para sempre.",
|
||||
"back": "Documentos",
|
||||
"entryLabel": "Excluídos recentemente ({count})",
|
||||
"loadError": "Não foi possível carregar Excluídos recentemente.",
|
||||
"emptyTitle": "Nada em Excluídos recentemente",
|
||||
"emptyDescription": "O que você exclui espera aqui por {days} dias antes de ser removido para sempre.",
|
||||
"count": "Documentos excluídos ({count})",
|
||||
"restoreAll": "Restaurar todos",
|
||||
"restore": "Restaurar",
|
||||
"purge": "Excluir agora",
|
||||
"deletedOn": "Excluído em {date}.",
|
||||
"removesIn": "Removido para sempre em {days} dias.",
|
||||
"removesSoon": "Removido para sempre em menos de um dia.",
|
||||
"restored": "Documento restaurado",
|
||||
"openRestored": "Abrir",
|
||||
"restoreDuplicate": "Já existe um documento idêntico salvo, então esta cópia não pode ser restaurada.",
|
||||
"restorePartial": "{failed} de {total} não puderam ser restaurados",
|
||||
"purged": "Documento excluído para sempre",
|
||||
"purgeFailed": "Não foi possível excluir o documento.",
|
||||
"purgeConfirmTitle": "Excluir este documento para sempre?",
|
||||
"purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
@@ -12610,7 +12863,8 @@
|
||||
"entryHint": "Quando {name} estiver pronto para cuidar deste registro por conta própria.",
|
||||
"methodSso": "{name} entra por login único ao usar o link.",
|
||||
"methodBoth": "{name} pode definir uma senha ou entrar por login único."
|
||||
}
|
||||
},
|
||||
"deleteBodyGrace": "Tudo registrado para {name} vai com o perfil: leituras, medicamentos, documentos e notas. O perfil é bloqueado na hora e apagado depois de {days} dias. Até lá, um administrador deste servidor pode desfazer a exclusão."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvo no registro de {name}"
|
||||
@@ -12665,6 +12919,7 @@
|
||||
"practitionerContactRestore": "{name} restaurou uma entrada do catálogo de endereços",
|
||||
"illnessDayLogUpsert": "{name} registrou um dia de doença",
|
||||
"medicationUpdate": "{name} alterou um medicamento",
|
||||
"medicationCourseEnd": "{name} encerrou um tratamento",
|
||||
"medicationDelete": "{name} removeu um medicamento",
|
||||
"medicationGlp1Update": "{name} alterou um curso de GLP-1",
|
||||
"medicationScheduleCreate": "{name} adicionou um cronograma de doses",
|
||||
@@ -12712,6 +12967,9 @@
|
||||
"rejectedDoseUpdate": "{name} tentou alterar uma dose e foi recusado",
|
||||
"rejectedDoseImport": "{name} tentou importar doses e foi recusado",
|
||||
"rejectedTrackedValue": "{name} tentou registrar um valor acompanhado e foi recusado",
|
||||
"rejectedTrackedValueTypeCreate": "{name} tentou adicionar um tipo de valor acompanhado e foi recusado",
|
||||
"rejectedTrackedValueTypeUpdate": "{name} tentou alterar um tipo de valor acompanhado e foi recusado",
|
||||
"rejectedMedicationLayout": "{name} tentou reorganizar a lista de medicamentos e foi recusado",
|
||||
"deniedDoseImport": "Uma importação de doses que {name} iniciou não pôde ser enfileirada",
|
||||
"anamnesisFactCreate": "{name} adicionou um fato do histórico de saúde",
|
||||
"customMetricCreate": "{name} adicionou um tipo de valor acompanhado",
|
||||
@@ -13021,7 +13279,8 @@
|
||||
"backgroundScan": "Lendo em segundo plano. Com um modelo lento, isso pode levar alguns minutos.",
|
||||
"waitingForWorker": "Aguardando o worker em segundo plano. Se isso não mudar, o worker do servidor pode não estar em execução.",
|
||||
"errorWorkerUnavailable": "O worker em segundo plano não está disponível agora. Tente novamente em instantes, ou peça ao operador do servidor para verificá-lo.",
|
||||
"errorTimedOut": "A leitura demorou mais do que o seu tempo de resposta de IA permite. Tente novamente, ou aumente o tempo de resposta nas configurações de IA."
|
||||
"errorTimedOut": "A leitura demorou mais do que o seu tempo de resposta de IA permite. Tente novamente, ou aumente o tempo de resposta nas configurações de IA.",
|
||||
"errorReaderBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Novo sintoma",
|
||||
|
||||
+16
-9
@@ -302,15 +302,22 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingIncludes: {
|
||||
"*": [
|
||||
"./src/lib/ai/prompts/safety-contracts.*.yaml",
|
||||
// Document AI local text extraction runs `pdf-parse` (→ pdfjs-dist)
|
||||
// server-side in the content-index job — the provider-free fallback that
|
||||
// keeps content search working with no AI configured. Both are pure JS +
|
||||
// wasm; there is NO native `.node` on the text-layer path (`@napi-rs/canvas`
|
||||
// is only pulled for image rendering, which `getText()` never invokes).
|
||||
// The standalone tracer can miss pdfjs-dist's wasm decoders (they resolve
|
||||
// via `new URL(..., import.meta.url)`), so pin both packages into the
|
||||
// runtime image explicitly. pnpm keeps pdfjs-dist unhoisted under `.pnpm`.
|
||||
"./node_modules/.pnpm/pdf-parse@*/node_modules/pdf-parse/**",
|
||||
// PDFs are read and rendered, and uploaded images decoded into their
|
||||
// thumbnails, in a child process with a memory ceiling and a time limit
|
||||
// (`src/lib/documents/pdf-isolation.ts`). The child is started from
|
||||
// these files by path, which the tracer cannot follow.
|
||||
"./src/lib/documents/pdf-child.mjs",
|
||||
"./src/lib/documents/pdf-engine.mjs",
|
||||
"./src/lib/documents/image-engine.mjs",
|
||||
// Document AI local text extraction and rasterization both run the
|
||||
// app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the
|
||||
// text read is the provider-free fallback that keeps content search
|
||||
// working with no AI configured. The standalone tracer can miss
|
||||
// pdfjs-dist's wasm decoders, standard fonts and CMaps (they are read
|
||||
// from disk at runtime), so pin the package into the runtime image
|
||||
// explicitly. pnpm keeps it unhoisted under `.pnpm`. The glob matches
|
||||
// one copy only: `src/__tests__/single-pdfjs-guard.test.ts` fails when
|
||||
// the lockfile resolves a second version (#1277).
|
||||
"./node_modules/.pnpm/pdfjs-dist@*/node_modules/pdfjs-dist/**",
|
||||
// Document AI rasterization (`src/lib/documents/rasterize-pdf.ts`) renders
|
||||
// a scanned/image-only PDF page to a JPEG via `@napi-rs/canvas` so an
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.45.0",
|
||||
"version": "1.46.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",
|
||||
@@ -46,6 +46,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"e2e": "playwright test",
|
||||
"e2e:critical": "playwright test --grep @critical",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"openapi:generate": "npx tsx scripts/generate-openapi.ts",
|
||||
"openapi:check": "npx tsx scripts/check-openapi.ts",
|
||||
@@ -120,6 +121,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitest/browser-playwright": "^5.0.5",
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
@@ -127,7 +129,6 @@
|
||||
"fast-check": "4.10.2",
|
||||
"knip": "^6.42.0",
|
||||
"lefthook": "^2.2.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
@@ -136,6 +137,7 @@
|
||||
"testcontainers": "^12.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^5.0.5"
|
||||
"vitest": "^5.0.5",
|
||||
"vitest-browser-react": "^2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
*
|
||||
* To run locally: `pnpm dlx playwright install --with-deps chromium`
|
||||
* once, then `pnpm e2e`.
|
||||
*
|
||||
* The journeys tagged `@critical` (`pnpm e2e:critical`) are the exception to
|
||||
* the route interception above: they intercept nothing under `/api`, which
|
||||
* `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds. Most of them
|
||||
* run in both Chromium projects on an account per project
|
||||
* (`e2e/setup/critical-fixture.ts`), so they need no entry in the
|
||||
* `chromium-mobile` ignore list below; the ones that are on that list are
|
||||
* there for the reason written beside them.
|
||||
*/
|
||||
/**
|
||||
* Drawn once here, before any server or worker exists, and published through
|
||||
@@ -115,6 +123,8 @@ export default defineConfig({
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// Runs only in the two tablet projects.
|
||||
"tablet-shell.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
@@ -232,6 +242,8 @@ export default defineConfig({
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// Runs only in the two tablet projects.
|
||||
"tablet-shell.spec.ts",
|
||||
// The Apple Health import journey uploads an archive into the one
|
||||
// shared account and its refusal control asserts that the account's
|
||||
// Apple-Health row count did not move. A second project running the
|
||||
@@ -325,7 +337,28 @@ export default defineConfig({
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
// The app shell on an iPad (`tablet-shell.spec.ts`): the rail
|
||||
// under a finger, `<main>` to its last line, scroll after a modal.
|
||||
// In CI's WebKit job beside `webkit-iphone`. The spec sets each
|
||||
// iPad size itself; the profile supplies touch and `isMobile`.
|
||||
{
|
||||
name: "webkit-tablet",
|
||||
testMatch: ["tablet-shell.spec.ts"],
|
||||
use: {
|
||||
...devices["iPad Pro 11 landscape"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
]),
|
||||
// The same spec on an Android tablet, in the Chromium shards.
|
||||
{
|
||||
name: "chromium-tablet",
|
||||
testMatch: ["tablet-shell.spec.ts"],
|
||||
use: {
|
||||
...devices["Galaxy Tab S4 landscape"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
|
||||
Generated
+181
-171
@@ -14,19 +14,18 @@ overrides:
|
||||
nanoid@<3.3.18: ^3.3.18
|
||||
'@hono/node-server@<1.19.15': ^1.19.15
|
||||
picomatch@<4.0.4: ^4.0.4
|
||||
body-parser@<2.3.0: ^2.3.0
|
||||
body-parser@<2.4.0: ^2.4.0
|
||||
source-map-js@<1.2.2: ^1.2.2
|
||||
postcss-selector-parser@<7.1.6: ^7.1.6
|
||||
smol-toml@<1.9.0: ^1.9.0
|
||||
qs@<6.16.0: ^6.16.0
|
||||
fast-uri@<3.1.8: ^3.1.8
|
||||
fast-uri@<3.1.9: ^3.1.9
|
||||
flatted@<3.4.2: ^3.4.2
|
||||
proxy-addr@<2.0.8: ^2.0.8
|
||||
mysql2@<3.22.0: ^3.22.0
|
||||
minimatch@<3.1.4: ^3.1.4
|
||||
ip-address@<10.7.1: ^10.7.1
|
||||
ip-address@<10.7.4: ^10.7.4
|
||||
sharp@<0.35.0: ^0.35.3
|
||||
pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108
|
||||
deepmerge-ts@<8.0.0: ^8.0.0
|
||||
valibot@<1.4.2: ^1.4.2
|
||||
'@humanfs/node@<0.16.8': ^0.16.8
|
||||
@@ -35,7 +34,7 @@ overrides:
|
||||
brace-expansion@<1.1.21: ^1.1.21
|
||||
brace-expansion@>=2.0.0 <2.1.7: ^2.1.7
|
||||
brace-expansion@>=4.0.0 <5.0.12: ^5.0.12
|
||||
undici@>=7.0.0 <7.29.1: ^7.29.1
|
||||
undici@>=7.0.0 <7.30.1: ^7.30.1
|
||||
vite@>=8.0.0 <8.1.6: ^8.1.6
|
||||
|
||||
patchedDependencies:
|
||||
@@ -241,9 +240,12 @@ importers:
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
'@vitest/browser-playwright':
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(vitest@5.0.5)
|
||||
version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@@ -262,9 +264,6 @@ importers:
|
||||
lefthook:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
pdfjs-dist:
|
||||
specifier: 6.4.299
|
||||
version: 6.4.299
|
||||
@@ -291,7 +290,10 @@ importers:
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
vitest-browser-react:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -708,6 +710,9 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@blazediff/core@1.10.0':
|
||||
resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==}
|
||||
|
||||
'@cfworker/json-schema@4.1.1':
|
||||
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||
|
||||
@@ -1232,61 +1237,30 @@ packages:
|
||||
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.13':
|
||||
resolution: {integrity: sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.13':
|
||||
resolution: {integrity: sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.13':
|
||||
resolution: {integrity: sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.13':
|
||||
resolution: {integrity: sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.13':
|
||||
resolution: {integrity: sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1294,13 +1268,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.13':
|
||||
resolution: {integrity: sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1308,13 +1275,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.13':
|
||||
resolution: {integrity: sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1322,13 +1282,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.13':
|
||||
resolution: {integrity: sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1336,13 +1289,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.13':
|
||||
resolution: {integrity: sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1356,22 +1302,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.13':
|
||||
resolution: {integrity: sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/canvas@1.0.13':
|
||||
resolution: {integrity: sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -3403,6 +3339,17 @@ packages:
|
||||
'@visx/vendor@4.0.0-alpha.0':
|
||||
resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==}
|
||||
|
||||
'@vitest/browser-playwright@5.0.5':
|
||||
resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 5.0.5
|
||||
|
||||
'@vitest/browser@5.0.5':
|
||||
resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==}
|
||||
peerDependencies:
|
||||
vitest: 5.0.5
|
||||
|
||||
'@vitest/coverage-v8@5.0.5':
|
||||
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==}
|
||||
peerDependencies:
|
||||
@@ -3431,9 +3378,20 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@5.0.5':
|
||||
resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==}
|
||||
|
||||
'@vitest/spy@5.0.5':
|
||||
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
|
||||
|
||||
'@vitest/ui@5.0.5':
|
||||
resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==}
|
||||
peerDependencies:
|
||||
vitest: 5.0.5
|
||||
|
||||
'@vitest/utils@5.0.5':
|
||||
resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
@@ -3699,8 +3657,8 @@ packages:
|
||||
bn.js@4.12.3:
|
||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||
|
||||
body-parser@2.3.0:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
body-parser@2.4.0:
|
||||
resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bowser@2.14.1:
|
||||
@@ -4524,8 +4482,8 @@ packages:
|
||||
fast-string-width@3.0.2:
|
||||
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||
|
||||
fast-uri@3.1.8:
|
||||
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||
fast-uri@3.1.9:
|
||||
resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
|
||||
@@ -4860,12 +4818,8 @@ packages:
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
ip-address@10.7.2:
|
||||
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ip-address@10.7.3:
|
||||
resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==}
|
||||
ip-address@10.7.4:
|
||||
resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
@@ -5882,15 +5836,6 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
|
||||
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
pdfjs-dist@6.4.299:
|
||||
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
@@ -5977,6 +5922,10 @@ packages:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6168,6 +6117,10 @@ packages:
|
||||
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.16.1:
|
||||
resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -6525,6 +6478,10 @@ packages:
|
||||
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
sirv@3.0.2:
|
||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
@@ -6925,8 +6882,8 @@ packages:
|
||||
undici-types@8.9.0:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
undici@7.30.0:
|
||||
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
|
||||
undici@7.30.1:
|
||||
resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@8.12.3:
|
||||
@@ -7061,6 +7018,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest-browser-react@2.3.0:
|
||||
resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
vitest: ^4.0.0 || ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@5.0.5:
|
||||
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
@@ -7193,6 +7164,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.22.0:
|
||||
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@1.0.1:
|
||||
resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -7881,6 +7864,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blazediff/core@1.10.0': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1':
|
||||
optional: true
|
||||
|
||||
@@ -7928,7 +7913,7 @@ snapshots:
|
||||
open: 8.4.2
|
||||
picomatch: 4.0.7
|
||||
systeminformation: 5.33.15
|
||||
undici: 7.30.0
|
||||
undici: 7.30.1
|
||||
which: 4.0.0
|
||||
yocto-spinner: 1.2.2
|
||||
|
||||
@@ -8404,82 +8389,39 @@ snapshots:
|
||||
strict-event-emitter: 0.5.1
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.13':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.80
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.80
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.80
|
||||
|
||||
'@napi-rs/canvas@1.0.13':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 1.0.13
|
||||
@@ -9923,7 +9865,7 @@ snapshots:
|
||||
stringify-object: 5.0.0
|
||||
tsconfig-paths: 4.2.0
|
||||
type-fest: 4.41.0
|
||||
undici: 7.30.0
|
||||
undici: 7.30.1
|
||||
zod: 4.6.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10046,7 +9988,7 @@ snapshots:
|
||||
'@stryker-mutator/util': 10.0.0
|
||||
semver: 7.8.5
|
||||
tslib: 2.8.1
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
@@ -10553,7 +10495,38 @@ snapshots:
|
||||
d3-time-format: 4.1.0
|
||||
internmap: 2.0.3
|
||||
|
||||
'@vitest/coverage-v8@5.0.5(vitest@5.0.5)':
|
||||
'@vitest/browser-playwright@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
|
||||
dependencies:
|
||||
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
|
||||
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
playwright: 1.64.1
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
|
||||
dependencies:
|
||||
'@blazediff/core': 1.10.0
|
||||
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
'@vitest/ui': 5.0.5(vitest@5.0.5)
|
||||
'@vitest/utils': 5.0.5
|
||||
magic-string: 1.4.3
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
ws: 8.22.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/coverage-v8@5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/istanbul-lib-coverage': 1.0.2
|
||||
@@ -10563,7 +10536,9 @@ snapshots:
|
||||
obug: 3.0.0
|
||||
std-env: 4.3.0
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
|
||||
|
||||
'@vitest/istanbul-lib-coverage@1.0.2': {}
|
||||
|
||||
@@ -10581,8 +10556,28 @@ snapshots:
|
||||
msw: 2.14.6(@types/node@26.6.5)(typescript@6.0.3)
|
||||
vite: 8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)
|
||||
|
||||
'@vitest/pretty-format@5.0.5':
|
||||
dependencies:
|
||||
tinyrainbow: 3.2.0
|
||||
|
||||
'@vitest/spy@5.0.5': {}
|
||||
|
||||
'@vitest/ui@5.0.5(vitest@5.0.5)':
|
||||
dependencies:
|
||||
'@vitest/utils': 5.0.5
|
||||
fflate: 0.8.3
|
||||
flatted: 3.4.4
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
|
||||
'@vitest/utils@5.0.5':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 5.0.5
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.2.0
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
@@ -10624,7 +10619,7 @@ snapshots:
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.8
|
||||
fast-uri: 3.1.9
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -10850,7 +10845,7 @@ snapshots:
|
||||
|
||||
bn.js@4.12.3: {}
|
||||
|
||||
body-parser@2.3.0(supports-color@7.2.0):
|
||||
body-parser@2.4.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.1.0
|
||||
@@ -10858,7 +10853,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.16.0
|
||||
qs: 6.16.1
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -11755,14 +11750,14 @@ snapshots:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
ip-address: 10.7.2
|
||||
ip-address: 10.7.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0(supports-color@7.2.0)
|
||||
body-parser: 2.4.0(supports-color@7.2.0)
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
@@ -11846,7 +11841,7 @@ snapshots:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
|
||||
fast-uri@3.1.8: {}
|
||||
fast-uri@3.1.9: {}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
dependencies:
|
||||
@@ -12165,9 +12160,7 @@ snapshots:
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
ip-address@10.7.2: {}
|
||||
|
||||
ip-address@10.7.3: {}
|
||||
ip-address@10.7.4: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
@@ -13118,15 +13111,6 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
pdfjs-dist: 5.4.296
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
|
||||
pdfjs-dist@6.4.299:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.13
|
||||
@@ -13207,6 +13191,8 @@ snapshots:
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-selector-parser@7.1.6:
|
||||
@@ -13355,6 +13341,11 @@ snapshots:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
qs@6.16.1:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
@@ -13802,7 +13793,7 @@ snapshots:
|
||||
stringify-object: 5.0.0
|
||||
ts-morph: 26.0.0
|
||||
tsconfig-paths: 4.2.0
|
||||
undici: 7.30.0
|
||||
undici: 7.30.1
|
||||
validate-npm-package-name: 7.0.2
|
||||
zod: 4.6.7
|
||||
transitivePeerDependencies:
|
||||
@@ -13888,6 +13879,12 @@ snapshots:
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
|
||||
sirv@3.0.2:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
@@ -13896,7 +13893,7 @@ snapshots:
|
||||
|
||||
socks@2.8.10:
|
||||
dependencies:
|
||||
ip-address: 10.7.3
|
||||
ip-address: 10.7.4
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
@@ -14352,7 +14349,7 @@ snapshots:
|
||||
|
||||
undici-types@8.9.0: {}
|
||||
|
||||
undici@7.30.0: {}
|
||||
undici@7.30.1: {}
|
||||
|
||||
undici@8.12.3: {}
|
||||
|
||||
@@ -14474,7 +14471,16 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
yaml: 2.9.1
|
||||
|
||||
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
|
||||
vitest-browser-react@2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
'@types/react-dom': 19.3.0(@types/react@19.3.0)
|
||||
|
||||
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
@@ -14493,7 +14499,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/node': 26.6.5
|
||||
'@vitest/coverage-v8': 5.0.5(vitest@5.0.5)
|
||||
'@vitest/browser-playwright': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
|
||||
'@vitest/coverage-v8': 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
|
||||
'@vitest/ui': 5.0.5(vitest@5.0.5)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -14616,6 +14624,8 @@ snapshots:
|
||||
|
||||
ws@7.5.13: {}
|
||||
|
||||
ws@8.22.0: {}
|
||||
|
||||
wsl-utils@1.0.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
|
||||
+12
-15
@@ -30,7 +30,9 @@ overrides:
|
||||
# for.
|
||||
"@hono/node-server@<1.19.15": "^1.19.15"
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
"body-parser@<2.3.0": "^2.3.0"
|
||||
# CVE-2026-111181 (HIGH) raised the floor from 2.3.0. The copy arrives
|
||||
# under express in the MCP SDK, on a production path.
|
||||
"body-parser@<2.4.0": "^2.4.0"
|
||||
# CVE-2026-93749 (source-map-js < 1.2.2, HIGH, under postcss and the
|
||||
# Tailwind build), CVE-2026-104844 (postcss-selector-parser < 7.1.6, under
|
||||
# shadcn) and GHSA-r4xh-jqrq-34v2 (smol-toml < 1.9.0, under knip). All three
|
||||
@@ -46,8 +48,9 @@ overrides:
|
||||
# malformed authorities. It arrives under ajv, including the connector SDK's
|
||||
# copy, so it sits on a path this app exposes.
|
||||
# Authority injection through an unvalidated port in serialize, and a
|
||||
# follow-up fixed in 3.1.8, raised the floor from 3.1.6.
|
||||
"fast-uri@<3.1.8": "^3.1.8"
|
||||
# follow-up fixed in 3.1.8, raised the floor from 3.1.6. CVE-2026-110180 and
|
||||
# CVE-2026-110306 raised it again to 3.1.9.
|
||||
"fast-uri@<3.1.9": "^3.1.9"
|
||||
"flatted@<3.4.2": "^3.4.2"
|
||||
# GHSA-jqcg-44mw-7w3h: IP spoofing through IPv4-mapped addresses. It
|
||||
# arrives under express in the MCP SDK; the app resolves client IPs itself,
|
||||
@@ -65,17 +68,10 @@ overrides:
|
||||
# 10.0.0.1 rather than being rejected. That is the exact alt-notation class
|
||||
# `isPublicUrl` exists to catch, and this copy arrives under the MCP SDK's
|
||||
# rate limiter where nothing of ours screens it.
|
||||
# Later advisories up to 10.7.0 raised the floor from 10.3.1 to 10.7.1.
|
||||
"ip-address@<10.7.1": "^10.7.1"
|
||||
# Later advisories raised the floor from 10.3.1 to 10.7.1, then to 10.7.4
|
||||
# (a ReDoS in the Address6 zone identifier, through 10.7.3).
|
||||
"ip-address@<10.7.4": "^10.7.4"
|
||||
"sharp@<0.35.0": "^0.35.3"
|
||||
# A crafted PDF could run arbitrary JavaScript through the renderer. Our own
|
||||
# copy is pinned past it in `devDependencies`, but a second copy arrives
|
||||
# transitively, and `outputFileTracingIncludes` ships EVERY `pdfjs-dist@*` in
|
||||
# the store into the standalone image — so an unused transitive copy is still
|
||||
# a copy in the runtime image. The range is bounded on both ends deliberately:
|
||||
# `pdf-parse` resolves 5.4.296, which predates the flaw and drives the local
|
||||
# text-extraction path, so it must not be dragged onto a new major.
|
||||
"pdfjs-dist@>=5.6.83 <6.2.108": "6.2.108"
|
||||
# Deeply nested input drives DeepmergeTS into unbounded recursion until the
|
||||
# stack gives out. The copy here is Prisma's: `@prisma/config` still asks for
|
||||
# 7.1.5 even on the current 7.9.1, so no Prisma bump reaches it and the
|
||||
@@ -99,12 +95,13 @@ overrides:
|
||||
|
||||
# Development-only copies (test containers, build tooling) that the
|
||||
# dependency alerts flag: gRPC under the test containers, brace-expansion on
|
||||
# three major lines, and undici 7 next to the app's own undici 8.
|
||||
# three major lines, and undici 7 next to the app's own undici 8
|
||||
# (CVE-2026-109869, CVE-2026-109841 and CVE-2026-109924 raised it to 7.30.1).
|
||||
"@grpc/grpc-js@>=1.14.0 <1.14.5": "^1.14.5"
|
||||
"brace-expansion@<1.1.21": "^1.1.21"
|
||||
"brace-expansion@>=2.0.0 <2.1.7": "^2.1.7"
|
||||
"brace-expansion@>=4.0.0 <5.0.12": "^5.0.12"
|
||||
"undici@>=7.0.0 <7.29.1": "^7.29.1"
|
||||
"undici@>=7.0.0 <7.30.1": "^7.30.1"
|
||||
|
||||
# GHSA-587h-7wmj-h5pm (HIGH), GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw,
|
||||
# GHSA-698v-4r3x-3vcv and GHSA-9jrq-w75r-8gcw: dev-server file serving,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# `prisma/`
|
||||
|
||||
The database layer. HealthLog runs PostgreSQL 16 via Prisma 7. This directory holds the single schema file and the forward-only migration history; the generated client is emitted elsewhere.
|
||||
The database layer. HealthLog runs on PostgreSQL 18 (the compose default) or 16 via Prisma 7. This directory holds the single schema file and the forward-only migration history; the generated client is emitted elsewhere.
|
||||
|
||||
## Contents
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Questionnaires in every app language: an administration records which text
|
||||
-- its answers were given in. "validated" is a published validated version,
|
||||
-- "unvalidated_translation" a translation written for the app (shown with a
|
||||
-- note), "original_en" the English original chosen by a reader of another
|
||||
-- language.
|
||||
--
|
||||
-- Nullable, no default, no backfill: earlier rows did not record it, and
|
||||
-- which text they showed depended on releases since replaced, so null reads
|
||||
-- as "not recorded" rather than as a guess.
|
||||
--
|
||||
-- Reversibility: dropping the column loses only that marker; the scores and
|
||||
-- the encrypted answers do not depend on it.
|
||||
--
|
||||
-- The lock wait is bounded inside a DO block and the file is safe to run
|
||||
-- again, so the boot retries it on a lock timeout.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "mental_health_assessments"
|
||||
ADD COLUMN IF NOT EXISTS "wording" TEXT;
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Per-token Apple Health write-back (#1272).
|
||||
--
|
||||
-- `api_tokens.mirror_to_apple_health` is the person's choice for a
|
||||
-- `measurements:write` token; `mirror_changed_at` is when it last changed and
|
||||
-- keys the `measurementMirror` domain of `/api/sync/changes`. Rows written by
|
||||
-- such a token keep their honest `EXTERNAL` source and point at the token
|
||||
-- through `measurements.ingest_token_id`; the server resolves the row's
|
||||
-- `appleHealthMirror` from the token when it answers it.
|
||||
--
|
||||
-- Each step is a `DO` block with a bounded lock wait, and each is safe to run
|
||||
-- again, so the file is marked for the boot's lock-timeout retry:
|
||||
--
|
||||
-- 1. the columns, and the foreign key added NOT VALID (no scan under the
|
||||
-- table lock);
|
||||
-- 2. the backfill: an existing EXTERNAL row is linked only where the
|
||||
-- account has exactly one measurement token (revoked ones counted) and
|
||||
-- the row is no older than that token, because only then is the token
|
||||
-- provably the one that carried it. Every other existing row stays
|
||||
-- unlinked and is not mirrored. The statement sets nothing but
|
||||
-- `ingest_token_id`, so `updated_at` and `sync_version` are untouched;
|
||||
-- 3. the foreign key validated, which scans without blocking writes.
|
||||
--
|
||||
-- The index the key and the feed walk use is built CONCURRENTLY in 0477, on
|
||||
-- its own, because a concurrent build cannot share a file.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "api_tokens"
|
||||
ADD COLUMN IF NOT EXISTS "mirror_to_apple_health" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "mirror_changed_at" TIMESTAMP(3);
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN IF NOT EXISTS "ingest_token_id" TEXT;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'measurements_ingest_token_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_ingest_token_id_fkey"
|
||||
FOREIGN KEY ("ingest_token_id") REFERENCES "api_tokens"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
linked bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
WITH sole_token AS (
|
||||
SELECT t."user_id",
|
||||
(array_agg(t."id"))[1] AS token_id,
|
||||
(array_agg(t."created_at"))[1] AS created_at
|
||||
FROM "api_tokens" t
|
||||
WHERE t."permissions" = ARRAY['measurements:write']::text[]
|
||||
GROUP BY t."user_id"
|
||||
HAVING count(*) = 1
|
||||
)
|
||||
UPDATE "measurements" m
|
||||
SET "ingest_token_id" = s.token_id
|
||||
FROM sole_token s
|
||||
WHERE m."user_id" = s."user_id"
|
||||
AND m."source" = 'EXTERNAL'
|
||||
AND m."ingest_token_id" IS NULL
|
||||
AND m."created_at" >= s.created_at;
|
||||
GET DIAGNOSTICS linked = ROW_COUNT;
|
||||
RAISE NOTICE '0476: linked % EXTERNAL readings to their sole measurement token', linked;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "measurements"
|
||||
VALIDATE CONSTRAINT "measurements_ingest_token_id_fkey";
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The rows a measurement token carried (#1272): walked by the
|
||||
-- `measurementMirror` domain of `/api/sync/changes` in id order, and looked up
|
||||
-- by the foreign key's SET NULL. Built CONCURRENTLY so writes to
|
||||
-- `measurements` carry on during the build; a concurrent build must be the
|
||||
-- only statement in its file. Most rows have no token, so the index stays
|
||||
-- small.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "measurements_ingest_token_id_id_idx"
|
||||
ON "measurements" ("ingest_token_id", "id");
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Give every PDF preview that failed to rasterize one more render.
|
||||
--
|
||||
-- Until this release the server could load two pdf.js versions. Once a local
|
||||
-- text read had claimed the in-process pdf.js worker, every later page render
|
||||
-- failed until a restart, and the thumbnail job recorded those documents as
|
||||
-- `raster-failed`. The backfill treats that reason as final, so their previews
|
||||
-- would never be tried again.
|
||||
--
|
||||
-- Only `raster-failed`: the clash surfaced as a rasterization that returned a
|
||||
-- failure, and the thumbnail path maps that to this reason and to no other.
|
||||
-- `error` comes from a throw, and the rasterizer never throws; `empty-render`
|
||||
-- and `pixel-cap` are decided after a render that worked.
|
||||
--
|
||||
-- A stored failure cannot say whether the clash caused it or the page really
|
||||
-- renders blank, so every such row goes. The boot-time thumbnail backfill
|
||||
-- finds each document again, renders it once, and a page that is really blank
|
||||
-- records `raster-failed` with one attempt and settles where it was. Running
|
||||
-- this again costs one more render per such document and nothing else.
|
||||
DELETE FROM "document_thumbnail_failures"
|
||||
WHERE "reason" = 'raster-failed';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Queue one more content-index attempt for every PDF whose last one failed in
|
||||
-- a way a pdf.js clash produces.
|
||||
--
|
||||
-- Until this release the server could load two pdf.js versions. Once a page
|
||||
-- render had claimed the in-process pdf.js worker, every later local text read
|
||||
-- failed until a restart, which the index records as `decrypt-error`; the
|
||||
-- other way round, a scanned PDF the picked provider could only read as images
|
||||
-- failed to rasterize and was recorded as `raster-failed`. Neither document has
|
||||
-- a content index, so neither is found by search, and on an instance without a
|
||||
-- configured AI provider nothing offers to index it again.
|
||||
--
|
||||
-- The marker is the state no index attempt ever leaves behind: a failure
|
||||
-- reason with no attempt time (the recorder always writes both). The boot
|
||||
-- discovery `document-index-retry` enqueues the auto-index job for exactly
|
||||
-- those rows, through the same provider-first, local-fallback decision and the
|
||||
-- same consent rules as an upload. The attempt writes its time back, so the
|
||||
-- row drops out whatever the outcome: a document that really cannot be read
|
||||
-- records its reason again and stays settled.
|
||||
--
|
||||
-- Only PDFs without an index and with a recorded attempt are touched; running
|
||||
-- this again queues those that failed once more for one more attempt.
|
||||
UPDATE "inbound_documents" AS d
|
||||
SET "last_index_attempt_at" = NULL
|
||||
WHERE d."deleted_at" IS NULL
|
||||
AND d."mime_type" = 'application/pdf'
|
||||
AND d."last_index_outcome" IN ('decrypt-error', 'raster-failed')
|
||||
AND d."last_index_attempt_at" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "document_content_index" AS c
|
||||
WHERE c."document_id" = d."id"
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Count the generations in a row that ended without a note, per status note.
|
||||
--
|
||||
-- A note whose generation failed opened a fixed five-minute negative window.
|
||||
-- A model that never answers kept failing every five minutes per note, and a
|
||||
-- note re-selected by the next data sync ignored the window altogether. The
|
||||
-- streak sets the next window: five minutes, doubling per failure in a row,
|
||||
-- up to two hours. A written or re-dated note resets it to zero.
|
||||
--
|
||||
-- Existing rows start at zero, which is the five-minute window they had.
|
||||
--
|
||||
-- The lock wait is bounded inside a DO block and the file is safe to run
|
||||
-- again, so the boot retries it on a lock timeout.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "insight_status_caches"
|
||||
ADD COLUMN IF NOT EXISTS "failure_streak" INTEGER NOT NULL DEFAULT 0;
|
||||
END
|
||||
$migration$;
|
||||
Loaded 100 of 2614 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user